@libraz/libsonare 1.5.5 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +109 -20
  2. package/dist/analysis.d.ts +5809 -0
  3. package/dist/analysis.js +2574 -0
  4. package/dist/analysis.js.map +1 -0
  5. package/dist/index.d.ts +884 -55
  6. package/dist/index.js +1302 -292
  7. package/dist/index.js.map +1 -1
  8. package/dist/schemas/realtime-voice-changer-preset-pack.schema.json +27 -0
  9. package/dist/schemas/realtime-voice-changer-preset.schema.json +140 -0
  10. package/dist/sonare-analysis.js +2 -0
  11. package/dist/sonare-analysis.wasm +0 -0
  12. package/dist/sonare.js +1 -1
  13. package/dist/sonare.wasm +0 -0
  14. package/dist/worker.d.ts +65 -0
  15. package/dist/worker.js +734 -0
  16. package/dist/worker.js.map +1 -0
  17. package/dist/worklet.d.ts +628 -88
  18. package/dist/worklet.js +1816 -266
  19. package/dist/worklet.js.map +1 -1
  20. package/package.json +23 -11
  21. package/src/_chain_config.ts +5 -3
  22. package/src/analysis.ts +137 -0
  23. package/src/analysis_helpers.ts +40 -29
  24. package/src/audio.ts +7 -2
  25. package/src/clip_page_streamer.ts +62 -8
  26. package/src/codes.ts +87 -64
  27. package/src/effects_mastering.ts +1 -0
  28. package/src/effects_transform.ts +179 -10
  29. package/src/errors.ts +1 -0
  30. package/src/feature_core.ts +140 -2
  31. package/src/feature_music.ts +25 -3
  32. package/src/feature_pitch.ts +70 -1
  33. package/src/feature_spectral.ts +295 -7
  34. package/src/feature_spectrogram.ts +203 -3
  35. package/src/features.ts +16 -1
  36. package/src/index.ts +120 -8
  37. package/src/mastering_chain.ts +20 -8
  38. package/src/mastering_core.ts +5 -0
  39. package/src/mastering_dynamics.ts +7 -1
  40. package/src/metering.ts +6 -1
  41. package/src/mixer.ts +3 -3
  42. package/src/opfs_clip_pages.ts +26 -1
  43. package/src/project.ts +13 -0
  44. package/src/project_class.ts +204 -23
  45. package/src/project_internal.ts +38 -34
  46. package/src/project_types.ts +114 -11
  47. package/src/public_types.ts +68 -0
  48. package/src/public_types_mastering.ts +22 -0
  49. package/src/public_types_mixing.ts +27 -2
  50. package/src/public_types_music.ts +9 -0
  51. package/src/public_types_realtime.ts +23 -5
  52. package/src/public_types_spectral.ts +33 -0
  53. package/src/quick_analysis.ts +46 -10
  54. package/src/realtime_engine.ts +167 -6
  55. package/src/realtime_voice_changer.ts +51 -77
  56. package/src/sonare-analysis.js.d.ts +8 -0
  57. package/src/sonare.js.d.ts +340 -6
  58. package/src/stream_analyzer.ts +6 -1
  59. package/src/streaming_processors.ts +15 -0
  60. package/src/web_midi.ts +46 -11
  61. package/src/worker.ts +196 -0
  62. package/src/worker_client.ts +352 -0
  63. package/src/worker_protocol.ts +59 -0
  64. package/src/worklet/engine-automation.ts +2 -1
  65. package/src/worklet/engine-capture-facade.ts +13 -4
  66. package/src/worklet/engine-clips.ts +12 -3
  67. package/src/worklet/engine-node.ts +264 -21
  68. package/src/worklet/engine-offline.ts +7 -2
  69. package/src/worklet/engine-parameter-facade.ts +55 -5
  70. package/src/worklet/engine-processor.ts +435 -62
  71. package/src/worklet/engine-register.ts +9 -1
  72. package/src/worklet/engine-strips.ts +19 -3
  73. package/src/worklet/engine-sync.ts +2 -6
  74. package/src/worklet/engine-tempo-facade.ts +8 -6
  75. package/src/worklet/engine.ts +390 -13
  76. package/src/worklet/guards.ts +121 -8
  77. package/src/worklet/messages.ts +166 -8
  78. package/src/worklet/protocol.ts +305 -17
  79. package/src/worklet/voice-changer-processor.ts +12 -15
  80. package/src/worklet.ts +20 -1
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,85 @@ 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 resolveOrdinalInRange(value, min, max, enumName) {
549
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < min || value > max) {
550
+ throw new RangeError(`Invalid ${enumName}: ${String(value)}`);
166
551
  }
552
+ return value;
167
553
  }
168
- 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}`);
554
+ function resolveEnumOrdinal(value, values, enumName) {
555
+ if (typeof value === "number") {
556
+ const ordinals = Object.values(values);
557
+ const ordinal = resolveOrdinalInRange(
558
+ value,
559
+ Math.min(...ordinals),
560
+ Math.max(...ordinals),
561
+ enumName
562
+ );
563
+ if (!ordinals.includes(ordinal)) {
564
+ throw new RangeError(`Invalid ${enumName}: ${String(value)}`);
565
+ }
566
+ return ordinal;
183
567
  }
568
+ if (typeof value === "string") {
569
+ const ordinal = values[value];
570
+ if (ordinal !== void 0) {
571
+ return ordinal;
572
+ }
573
+ }
574
+ throw new RangeError(`Invalid ${enumName}: ${String(value)}`);
575
+ }
576
+ var AUTOMATION_CURVE_VALUES = {
577
+ linear: 0,
578
+ exponential: 1,
579
+ hold: 2,
580
+ "s-curve": 3
581
+ };
582
+ var PAN_LAW_VALUES = {
583
+ const3db: 0,
584
+ "const-3db": 0,
585
+ "-3db": 0,
586
+ "const4.5db": 1,
587
+ "const-4.5db": 1,
588
+ "-4.5db": 1,
589
+ const6db: 2,
590
+ "const-6db": 2,
591
+ "-6db": 2,
592
+ linear0db: 3,
593
+ "linear-0db": 3,
594
+ linear: 3,
595
+ "0db": 3
596
+ };
597
+ var PAN_MODE_VALUES = {
598
+ balance: 0,
599
+ pan: 1,
600
+ stereopan: 1,
601
+ "stereo-pan": 1,
602
+ dualpan: 2,
603
+ "dual-pan": 2
604
+ };
605
+ var METER_TAP_VALUES = { preFader: 0, postFader: 1 };
606
+ var SEND_TIMING_VALUES = { postFader: 0, preFader: 1 };
607
+ var TRACK_MONITOR_MODE_VALUES = { off: 0, pfl: 1, afl: 2 };
608
+ function automationCurveCode(curve) {
609
+ return resolveEnumOrdinal(curve, AUTOMATION_CURVE_VALUES, "automation curve");
610
+ }
611
+ function panLawCode(panLaw) {
612
+ const normalized = typeof panLaw === "string" ? panLaw.toLowerCase().replace(/_/g, "-") : panLaw;
613
+ return resolveEnumOrdinal(normalized, PAN_LAW_VALUES, "pan law");
184
614
  }
185
615
  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
- }
616
+ const normalized = typeof panMode === "string" ? panMode.replace(/_/g, "-").toLowerCase() : panMode;
617
+ return resolveEnumOrdinal(normalized, PAN_MODE_VALUES, "pan mode");
201
618
  }
202
619
  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
- }
620
+ return resolveEnumOrdinal(tap, METER_TAP_VALUES, "meter tap");
214
621
  }
215
622
  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
- }
623
+ return resolveEnumOrdinal(timing, SEND_TIMING_VALUES, "send timing");
624
+ }
625
+ function trackMonitorModeCode(mode) {
626
+ return resolveEnumOrdinal(mode, TRACK_MONITOR_MODE_VALUES, "track monitor mode");
227
627
  }
228
628
 
229
629
  // src/realtime_engine.ts
@@ -244,7 +644,7 @@ function engineCapabilities() {
244
644
  };
245
645
  }
246
646
  var RealtimeEngine = class {
247
- constructor(sampleRate = 48e3, maxBlockSize = 128, commandCapacity = 1024, telemetryCapacity = 1024) {
647
+ constructor(sampleRate = 48e3, maxBlockSize = 128, commandCapacity = 1024, telemetryCapacity = 1024, maxChannels = 64) {
248
648
  const module2 = getSonareModule();
249
649
  const capabilities = engineCapabilities();
250
650
  if (!capabilities.abiCompatible) {
@@ -256,11 +656,18 @@ var RealtimeEngine = class {
256
656
  sampleRate,
257
657
  maxBlockSize,
258
658
  commandCapacity,
259
- telemetryCapacity
659
+ telemetryCapacity,
660
+ maxChannels
260
661
  );
261
662
  }
262
- prepare(sampleRate, maxBlockSize, commandCapacity = 1024, telemetryCapacity = 1024) {
263
- this.native.prepare(sampleRate, maxBlockSize, commandCapacity, telemetryCapacity);
663
+ prepare(sampleRate, maxBlockSize, commandCapacity = 1024, telemetryCapacity = 1024, maxChannels = 64) {
664
+ this.native.prepareWithChannels(
665
+ sampleRate,
666
+ maxBlockSize,
667
+ commandCapacity,
668
+ telemetryCapacity,
669
+ maxChannels
670
+ );
264
671
  }
265
672
  /** Queue a sample-accurate parameter change (engine kSetParam). */
266
673
  setParameter(paramId, value, renderFrame = -1) {
@@ -281,6 +688,10 @@ var RealtimeEngine = class {
281
688
  setSoloMute(laneIndex, solo, mute, renderFrame = -1) {
282
689
  this.native.setSoloMute(laneIndex, solo, mute, renderFrame);
283
690
  }
691
+ /** Queue a per-track PFL/AFL monitor tap mode change. */
692
+ setTrackMonitorMode(laneIndex, mode, renderFrame = -1) {
693
+ this.native.setTrackMonitorMode(laneIndex, trackMonitorModeCode(mode), renderFrame);
694
+ }
284
695
  setMidiClips(clips) {
285
696
  this.native.setMidiClips(clips);
286
697
  }
@@ -386,6 +797,9 @@ var RealtimeEngine = class {
386
797
  externalMidiDroppedCount() {
387
798
  return this.native.externalMidiDroppedCount();
388
799
  }
800
+ externalMidiPendingCount() {
801
+ return this.native.externalMidiPendingCount();
802
+ }
389
803
  /**
390
804
  * Drain queued external-MIDI events, already lowered to MIDI 1.0 byte
391
805
  * messages ready to write to a Web MIDI output port. Call once per audio
@@ -396,6 +810,25 @@ var RealtimeEngine = class {
396
810
  drainExternalMidi(maxRecords = 1024) {
397
811
  return this.native.drainExternalMidi(maxRecords);
398
812
  }
813
+ /** Scalar, allocation-free external-MIDI drain for AudioWorklet SAB output. */
814
+ popExternalMidiToScratch() {
815
+ return this.native.popExternalMidiToScratch();
816
+ }
817
+ externalMidiScratchDestinationId() {
818
+ return this.native.externalMidiScratchDestinationId();
819
+ }
820
+ externalMidiScratchRenderFrame() {
821
+ return this.native.externalMidiScratchRenderFrame();
822
+ }
823
+ externalMidiScratchByteWord() {
824
+ return this.native.externalMidiScratchByteWord();
825
+ }
826
+ externalMidiScratchByteCount() {
827
+ return this.native.externalMidiScratchByteCount();
828
+ }
829
+ consumeExternalMidiScratch() {
830
+ this.native.consumeExternalMidiScratch();
831
+ }
399
832
  pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples = 0) {
400
833
  this.native.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
401
834
  }
@@ -420,6 +853,10 @@ var RealtimeEngine = class {
420
853
  pushMidiCc(destinationId, group, channel, controller, value, renderFrame = -1) {
421
854
  this.native.pushMidiCc(destinationId, group, channel, controller, value, renderFrame);
422
855
  }
856
+ /** Queue one immediate MIDI 1.0 channel-voice UMP word for a destination. */
857
+ pushMidiUmp(destinationId, word0, renderFrame = -1) {
858
+ this.native.pushMidiUmp(destinationId, word0, renderFrame);
859
+ }
423
860
  /**
424
861
  * Queue an immediate (live) MIDI SysEx frame to a MIDI destination. `data` is
425
862
  * the full message including the leading 0xF0 and trailing 0xF7 (1..512
@@ -465,6 +902,10 @@ var RealtimeEngine = class {
465
902
  settleParameters() {
466
903
  this.native.settleParameters();
467
904
  }
905
+ /** Drains queued commands on an offline/control-only engine immediately. */
906
+ flushControlCommands() {
907
+ this.native.flushControlCommands();
908
+ }
468
909
  seekPpq(ppq, renderFrame = -1) {
469
910
  this.native.seekPpq(ppq, renderFrame);
470
911
  }
@@ -719,6 +1160,23 @@ var RealtimeEngine = class {
719
1160
  popClipPageRequest() {
720
1161
  return this.native.popClipPageRequest();
721
1162
  }
1163
+ /**
1164
+ * Moves one native request into the binding's persistent scalar scratch.
1165
+ * This avoids creating an embind JS object in AudioWorklet process().
1166
+ */
1167
+ popClipPageRequestToScratch() {
1168
+ return this.native.popClipPageRequestToScratch();
1169
+ }
1170
+ clipPageRequestScratchClipId() {
1171
+ return this.native.clipPageRequestScratchClipId();
1172
+ }
1173
+ clipPageRequestScratchSample() {
1174
+ return this.native.clipPageRequestScratchSample();
1175
+ }
1176
+ /** Cumulative page misses dropped because the native bounded request queue was full. */
1177
+ clipPageRequestOverflowCount() {
1178
+ return this.native.clipPageRequestOverflowCount();
1179
+ }
722
1180
  setCaptureBuffer(numChannels, capacityFrames) {
723
1181
  this.native.setCaptureBuffer(numChannels, capacityFrames);
724
1182
  }
@@ -747,6 +1205,10 @@ var RealtimeEngine = class {
747
1205
  capturedAudio() {
748
1206
  return this.native.capturedAudio();
749
1207
  }
1208
+ /**
1209
+ * Renders in place, adding engine output to `channels`. Zero each plane first
1210
+ * when it contains no upstream input.
1211
+ */
750
1212
  process(channels) {
751
1213
  return this.native.process(channels);
752
1214
  }
@@ -769,6 +1231,7 @@ var RealtimeEngine = class {
769
1231
  }
770
1232
  /**
771
1233
  * Runs the engine in place over the prepared per-channel scratch buffers.
1234
+ * Zero each active span first when it contains no upstream input.
772
1235
  * Allocation-free: safe to call on the AudioWorklet render thread after
773
1236
  * `prepareChannels`.
774
1237
  */
@@ -790,6 +1253,42 @@ var RealtimeEngine = class {
790
1253
  drainTelemetry(maxRecords = 1024) {
791
1254
  return this.native.drainTelemetry(maxRecords);
792
1255
  }
1256
+ popTelemetryToScratch() {
1257
+ return this.native.popTelemetryToScratch();
1258
+ }
1259
+ telemetryScratchType() {
1260
+ return this.native.telemetryScratchType();
1261
+ }
1262
+ telemetryScratchError() {
1263
+ return this.native.telemetryScratchError();
1264
+ }
1265
+ telemetryScratchRenderFrame() {
1266
+ return Number(this.native.telemetryScratchRenderFrame());
1267
+ }
1268
+ telemetryScratchTimelineSample() {
1269
+ return Number(this.native.telemetryScratchTimelineSample());
1270
+ }
1271
+ telemetryScratchAudibleTimelineSample() {
1272
+ return Number(this.native.telemetryScratchAudibleTimelineSample());
1273
+ }
1274
+ telemetryScratchGraphLatencySamplesQ8() {
1275
+ return this.native.telemetryScratchGraphLatencySamplesQ8();
1276
+ }
1277
+ telemetryScratchValue() {
1278
+ return this.native.telemetryScratchValue();
1279
+ }
1280
+ popMeterTelemetryToScratch() {
1281
+ return this.native.popMeterTelemetryToScratch();
1282
+ }
1283
+ meterScratchTargetId() {
1284
+ return this.native.meterScratchTargetId();
1285
+ }
1286
+ meterScratchRenderFrame() {
1287
+ return Number(this.native.meterScratchRenderFrame());
1288
+ }
1289
+ meterScratchValue(field) {
1290
+ return this.native.meterScratchValue(field);
1291
+ }
793
1292
  drainMeterTelemetry(maxRecords = 1024) {
794
1293
  return this.native.drainMeterTelemetry(maxRecords);
795
1294
  }
@@ -817,6 +1316,30 @@ var RealtimeEngine = class {
817
1316
  drainScopeTelemetry(maxRecords = 1024) {
818
1317
  return this.native.drainScopeTelemetry(maxRecords);
819
1318
  }
1319
+ popScopeTelemetryToScratch() {
1320
+ return this.native.popScopeTelemetryToScratch();
1321
+ }
1322
+ scopeScratchTargetId() {
1323
+ return this.native.scopeScratchTargetId();
1324
+ }
1325
+ scopeScratchRenderFrame() {
1326
+ return Number(this.native.scopeScratchRenderFrame());
1327
+ }
1328
+ scopeScratchBandCount() {
1329
+ return this.native.scopeScratchBandCount();
1330
+ }
1331
+ scopeScratchBand(index) {
1332
+ return this.native.scopeScratchBand(index);
1333
+ }
1334
+ scopeScratchPointCount() {
1335
+ return this.native.scopeScratchPointCount();
1336
+ }
1337
+ scopeScratchPointLeft(index) {
1338
+ return this.native.scopeScratchPointLeft(index);
1339
+ }
1340
+ scopeScratchPointRight(index) {
1341
+ return this.native.scopeScratchPointRight(index);
1342
+ }
820
1343
  destroy() {
821
1344
  this.native.delete();
822
1345
  }
@@ -1087,7 +1610,7 @@ var Mixer = class _Mixer {
1087
1610
  setPolarityInvert(stripIndex, invertLeft, invertRight) {
1088
1611
  this.mixer.setPolarityInvert(stripIndex, invertLeft, invertRight);
1089
1612
  }
1090
- /** Set the strip's pan law. */
1613
+ /** Set the strip's pan law (a {@link PanLawName} alias or raw C ABI ordinal). */
1091
1614
  setPanLaw(stripIndex, panLaw) {
1092
1615
  this.mixer.setPanLaw(stripIndex, panLawCode(panLaw));
1093
1616
  }
@@ -1274,65 +1797,18 @@ var Mixer = class _Mixer {
1274
1797
  };
1275
1798
 
1276
1799
  // 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
1800
  var RealtimeVoiceChanger = class {
1333
- constructor(config = "neutral-monitor") {
1801
+ /**
1802
+ * Creates a voice changer. Supplying `sampleRate` prepares it immediately,
1803
+ * matching the Node and Python constructors; omitting it preserves the
1804
+ * explicit {@link prepare} lifecycle for callers that configure later.
1805
+ */
1806
+ constructor(config = "neutral-monitor", sampleRate, maxBlockSize = 128, channels = 1) {
1334
1807
  const module2 = getSonareModule();
1335
1808
  this.changer = module2.createRealtimeVoiceChanger(config);
1809
+ if (sampleRate !== void 0) {
1810
+ this.changer.prepare(sampleRate, maxBlockSize, channels);
1811
+ }
1336
1812
  }
1337
1813
  prepare(sampleRate, maxBlockSize = 128, channels = 1) {
1338
1814
  this.changer.prepare(sampleRate, maxBlockSize, channels);
@@ -1341,8 +1817,15 @@ var RealtimeVoiceChanger = class {
1341
1817
  this.changer.reset();
1342
1818
  }
1343
1819
  setConfig(config) {
1344
- const resolved = isFlatVoiceChangerPod(config) ? flatVoiceChangerPodToNested(config) : config;
1345
- this.changer.setConfig(resolved);
1820
+ this.changer.setConfig(config);
1821
+ }
1822
+ /**
1823
+ * Apply a flat, pre-normalized config without JSON serialization. Intended
1824
+ * for AudioWorklet control messages whose sender prepared the POD on the
1825
+ * main thread.
1826
+ */
1827
+ setPodConfig(config) {
1828
+ this.changer.setPodConfig(config);
1346
1829
  }
1347
1830
  configJson() {
1348
1831
  return this.changer.configJson();
@@ -1350,6 +1833,13 @@ var RealtimeVoiceChanger = class {
1350
1833
  latencySamples() {
1351
1834
  return this.changer.latencySamples();
1352
1835
  }
1836
+ /**
1837
+ * Monotonically increases whenever {@link prepare} can replace the native
1838
+ * scratch buffers. Cached WASM heap views must be reacquired after it changes.
1839
+ */
1840
+ bufferGeneration() {
1841
+ return this.changer.bufferGeneration();
1842
+ }
1353
1843
  processMono(samples) {
1354
1844
  return this.changer.processMono(samples);
1355
1845
  }
@@ -1426,10 +1916,12 @@ var RealtimeVoiceChanger = class {
1426
1916
  createRealtimeMonoBuffer(numSamples) {
1427
1917
  let input = this.getMonoInputBuffer(numSamples);
1428
1918
  let output = this.getMonoOutputBuffer(numSamples);
1919
+ let generation = this.bufferGeneration();
1429
1920
  const reacquireIfDetached = () => {
1430
- if (input.byteLength === 0 || output.byteLength === 0) {
1921
+ if (generation !== this.bufferGeneration() || input.byteLength === 0 || output.byteLength === 0) {
1431
1922
  input = this.getMonoInputBuffer(numSamples);
1432
1923
  output = this.getMonoOutputBuffer(numSamples);
1924
+ generation = this.bufferGeneration();
1433
1925
  }
1434
1926
  };
1435
1927
  return {
@@ -1451,10 +1943,12 @@ var RealtimeVoiceChanger = class {
1451
1943
  createRealtimeInterleavedBuffer(numFrames, numChannels) {
1452
1944
  let input = this.getInterleavedInputBuffer(numFrames, numChannels);
1453
1945
  let output = this.getInterleavedOutputBuffer(numFrames, numChannels);
1946
+ let generation = this.bufferGeneration();
1454
1947
  const reacquireIfDetached = () => {
1455
- if (input.byteLength === 0 || output.byteLength === 0) {
1948
+ if (generation !== this.bufferGeneration() || input.byteLength === 0 || output.byteLength === 0) {
1456
1949
  input = this.getInterleavedInputBuffer(numFrames, numChannels);
1457
1950
  output = this.getInterleavedOutputBuffer(numFrames, numChannels);
1951
+ generation = this.bufferGeneration();
1458
1952
  }
1459
1953
  };
1460
1954
  return {
@@ -1481,15 +1975,17 @@ var RealtimeVoiceChanger = class {
1481
1975
  */
1482
1976
  createRealtimePlanarBuffer(numFrames, numChannels) {
1483
1977
  let channels = [];
1978
+ let generation = this.bufferGeneration();
1484
1979
  const acquire = () => {
1485
1980
  channels = [];
1486
1981
  for (let ch = 0; ch < numChannels; ch++) {
1487
1982
  channels.push(this.getPlanarChannelBuffer(ch, numFrames));
1488
1983
  }
1984
+ generation = this.bufferGeneration();
1489
1985
  };
1490
1986
  acquire();
1491
1987
  const reacquireIfDetached = () => {
1492
- if ((channels[0]?.byteLength ?? 0) === 0) {
1988
+ if (generation !== this.bufferGeneration() || (channels[0]?.byteLength ?? 0) === 0) {
1493
1989
  acquire();
1494
1990
  }
1495
1991
  };
@@ -1571,7 +2067,7 @@ function resolveParamId(parameters, nodeId, param) {
1571
2067
  if (byName) {
1572
2068
  return byName.id;
1573
2069
  }
1574
- return resolveTargetId(param || nodeId);
2070
+ throw new RangeError(`Unknown engine parameter ${JSON.stringify(param)} for node ${nodeId}`);
1575
2071
  }
1576
2072
  function curveCode(curve) {
1577
2073
  if (typeof curve === "number") {
@@ -1636,12 +2132,16 @@ function buildTransportFacade(ctx) {
1636
2132
  },
1637
2133
  seekPpq: (ppq, sampleTime = -1) => {
1638
2134
  ctx.offlineEngine.seekPpq(ppq, sampleTime);
1639
- return ctx.realtimeNode.seekPpq(ppq, sampleTime);
2135
+ const ok = ctx.realtimeNode.seekPpq(ppq, sampleTime);
2136
+ ctx.flushOfflineMirror();
2137
+ return ok;
1640
2138
  },
1641
2139
  seekSeconds: (seconds, sampleTime = -1) => {
1642
2140
  const timelineSample = Math.max(0, Math.round(seconds * ctx.sampleRate));
1643
2141
  ctx.offlineEngine.seekSample(timelineSample, sampleTime);
1644
- return ctx.realtimeNode.seekSample(timelineSample, sampleTime);
2142
+ const ok = ctx.realtimeNode.seekSample(timelineSample, sampleTime);
2143
+ ctx.flushOfflineMirror();
2144
+ return ok;
1645
2145
  },
1646
2146
  setTempo: (bpm) => ctx.setTempo(bpm),
1647
2147
  setTempoSegments: (segments) => ctx.setTempoSegments(segments),
@@ -1728,6 +2228,10 @@ function decodeFrame(lo, hi) {
1728
2228
  var SONARE_ENGINE_RING_HEADER_INTS = 5;
1729
2229
  var SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
1730
2230
  var SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
2231
+ var SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS = 5;
2232
+ var SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S = 2;
2233
+ var SONARE_EXTERNAL_MIDI_RING_HEADER_INTS = 5;
2234
+ var SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S = 4;
1731
2235
  var SonareEngineCommandType = /* @__PURE__ */ ((SonareEngineCommandType2) => {
1732
2236
  SonareEngineCommandType2[SonareEngineCommandType2["SetParam"] = 0] = "SetParam";
1733
2237
  SonareEngineCommandType2[SonareEngineCommandType2["SetParamSmoothed"] = 1] = "SetParamSmoothed";
@@ -1747,6 +2251,7 @@ var SonareEngineCommandType = /* @__PURE__ */ ((SonareEngineCommandType2) => {
1747
2251
  SonareEngineCommandType2[SonareEngineCommandType2["SetMetronome"] = 15] = "SetMetronome";
1748
2252
  SonareEngineCommandType2[SonareEngineCommandType2["SetMarker"] = 16] = "SetMarker";
1749
2253
  SonareEngineCommandType2[SonareEngineCommandType2["SeekMarker"] = 17] = "SeekMarker";
2254
+ SonareEngineCommandType2[SonareEngineCommandType2["SetTrackMonitorMode"] = 26] = "SetTrackMonitorMode";
1750
2255
  return SonareEngineCommandType2;
1751
2256
  })(SonareEngineCommandType || {});
1752
2257
  var SonareEngineTelemetryType = /* @__PURE__ */ ((SonareEngineTelemetryType2) => {
@@ -1774,6 +2279,8 @@ var SonareEngineTelemetryError = /* @__PURE__ */ ((SonareEngineTelemetryError2)
1774
2279
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["InsertAutomationOverflow"] = 16] = "InsertAutomationOverflow";
1775
2280
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["MidiClockOverflow"] = 17] = "MidiClockOverflow";
1776
2281
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["MetronomeOverflow"] = 18] = "MetronomeOverflow";
2282
+ SonareEngineTelemetryError2[SonareEngineTelemetryError2["InvalidCommand"] = 19] = "InvalidCommand";
2283
+ SonareEngineTelemetryError2[SonareEngineTelemetryError2["MaxChannelsExceeded"] = 20] = "MaxChannelsExceeded";
1777
2284
  return SonareEngineTelemetryError2;
1778
2285
  })(SonareEngineTelemetryError || {});
1779
2286
  function toDb(value) {
@@ -1971,6 +2478,128 @@ function sonareEngineTelemetryRingBufferByteLength(capacity) {
1971
2478
  const clampedCapacity = Math.max(1, Math.floor(capacity));
1972
2479
  return SONARE_ENGINE_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * SONARE_ENGINE_TELEMETRY_RECORD_BYTES;
1973
2480
  }
2481
+ function sonareClipPageRequestRingBufferByteLength(capacity) {
2482
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2483
+ return SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S * Uint32Array.BYTES_PER_ELEMENT;
2484
+ }
2485
+ function sonareExternalMidiRingBufferByteLength(capacity) {
2486
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2487
+ return SONARE_EXTERNAL_MIDI_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S * Uint32Array.BYTES_PER_ELEMENT;
2488
+ }
2489
+ function externalMidiRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
2490
+ const headerBytes = SONARE_EXTERNAL_MIDI_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
2491
+ const header = new Int32Array(sharedBuffer, 0, SONARE_EXTERNAL_MIDI_RING_HEADER_INTS);
2492
+ const existingCapacity = Atomics.load(header, 2);
2493
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
2494
+ if (sharedBuffer.byteLength < sonareExternalMidiRingBufferByteLength(capacity)) {
2495
+ throw new Error("externalMidiSharedBuffer is too small for the requested ring capacity.");
2496
+ }
2497
+ Atomics.store(header, 2, capacity);
2498
+ Atomics.store(header, 3, SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S);
2499
+ return {
2500
+ header,
2501
+ records: new Uint32Array(
2502
+ sharedBuffer,
2503
+ headerBytes,
2504
+ capacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S
2505
+ ),
2506
+ capacity
2507
+ };
2508
+ }
2509
+ function createSonareExternalMidiRingBuffer(capacity = 256) {
2510
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2511
+ const sharedBuffer = new SharedArrayBuffer(
2512
+ sonareExternalMidiRingBufferByteLength(clampedCapacity)
2513
+ );
2514
+ const ring = externalMidiRingFromSharedBuffer(sharedBuffer, clampedCapacity);
2515
+ Atomics.store(ring.header, 0, 0);
2516
+ Atomics.store(ring.header, 1, 0);
2517
+ Atomics.store(ring.header, 4, 0);
2518
+ return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
2519
+ }
2520
+ function pushSonareExternalMidiRingBuffer(ring, destinationId, renderFrame, byteWord, byteCount) {
2521
+ const destination = Number(destinationId);
2522
+ const frame = Number(renderFrame);
2523
+ const packedBytes = Number(byteWord);
2524
+ const count = Number(byteCount);
2525
+ if (!Number.isSafeInteger(destination) || !Number.isSafeInteger(frame) || frame < 0 || !Number.isSafeInteger(packedBytes) || packedBytes < 0 || packedBytes > 16777215 || !Number.isSafeInteger(count) || count < 1 || count > 3) {
2526
+ Atomics.add(ring.header, 4, 1);
2527
+ return false;
2528
+ }
2529
+ const writeIndex = Atomics.load(ring.header, 0);
2530
+ const readIndex = Atomics.load(ring.header, 1);
2531
+ if (writeIndex - readIndex >= ring.capacity) {
2532
+ Atomics.add(ring.header, 4, 1);
2533
+ return false;
2534
+ }
2535
+ const offset = writeIndex % ring.capacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S;
2536
+ Atomics.store(ring.records, offset, destination);
2537
+ Atomics.store(ring.records, offset + 1, frame);
2538
+ Atomics.store(ring.records, offset + 2, packedBytes);
2539
+ Atomics.store(ring.records, offset + 3, count);
2540
+ Atomics.store(ring.header, 0, writeIndex + 1);
2541
+ return true;
2542
+ }
2543
+ function readSonareExternalMidiRingBuffer(ring) {
2544
+ const readIndex = Atomics.load(ring.header, 1);
2545
+ const writeIndex = Atomics.load(ring.header, 0);
2546
+ const events = [];
2547
+ for (let index = readIndex; index < writeIndex; index++) {
2548
+ const offset = index % ring.capacity * SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S;
2549
+ events.push({
2550
+ destinationId: Atomics.load(ring.records, offset) >>> 0,
2551
+ renderFrame: Atomics.load(ring.records, offset + 1) >>> 0,
2552
+ byteWord: Atomics.load(ring.records, offset + 2) >>> 0,
2553
+ byteCount: Atomics.load(ring.records, offset + 3) >>> 0
2554
+ });
2555
+ }
2556
+ Atomics.store(ring.header, 1, writeIndex);
2557
+ return { events, dropped: Atomics.load(ring.header, 4) >>> 0 };
2558
+ }
2559
+ function createSonareClipPageRequestRingBuffer(capacity = 128) {
2560
+ const clampedCapacity = Math.max(1, Math.floor(capacity));
2561
+ const sharedBuffer = new SharedArrayBuffer(
2562
+ sonareClipPageRequestRingBufferByteLength(clampedCapacity)
2563
+ );
2564
+ const ring = clipPageRequestRingFromSharedBuffer(sharedBuffer, clampedCapacity);
2565
+ Atomics.store(ring.header, 0, 0);
2566
+ Atomics.store(ring.header, 1, 0);
2567
+ Atomics.store(ring.header, 2, ring.capacity);
2568
+ Atomics.store(ring.header, 3, SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S);
2569
+ Atomics.store(ring.header, 4, 0);
2570
+ return { sharedBuffer, header: ring.header, records: ring.records, capacity: ring.capacity };
2571
+ }
2572
+ function pushSonareClipPageRequestRingBuffer(ring, clipId, pageIndex) {
2573
+ if (!Number.isSafeInteger(clipId) || clipId < 0 || clipId > 4294967295 || !Number.isSafeInteger(pageIndex) || pageIndex < 0 || pageIndex > 4294967295) {
2574
+ Atomics.add(ring.header, 4, 1);
2575
+ return false;
2576
+ }
2577
+ const writeIndex = Atomics.load(ring.header, 0);
2578
+ const readIndex = Atomics.load(ring.header, 1);
2579
+ if (writeIndex - readIndex >= ring.capacity) {
2580
+ Atomics.add(ring.header, 4, 1);
2581
+ return false;
2582
+ }
2583
+ const offset = writeIndex % ring.capacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S;
2584
+ Atomics.store(ring.records, offset, clipId);
2585
+ Atomics.store(ring.records, offset + 1, pageIndex);
2586
+ Atomics.store(ring.header, 0, writeIndex + 1);
2587
+ return true;
2588
+ }
2589
+ function readSonareClipPageRequestRingBuffer(ring) {
2590
+ const readIndex = Atomics.load(ring.header, 1);
2591
+ const writeIndex = Atomics.load(ring.header, 0);
2592
+ const requests = [];
2593
+ for (let index = readIndex; index < writeIndex; index++) {
2594
+ const offset = index % ring.capacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S;
2595
+ requests.push({
2596
+ clipId: Atomics.load(ring.records, offset),
2597
+ pageIndex: Atomics.load(ring.records, offset + 1)
2598
+ });
2599
+ }
2600
+ Atomics.store(ring.header, 1, writeIndex);
2601
+ return { requests, dropped: Atomics.load(ring.header, 4) >>> 0 };
2602
+ }
1974
2603
  function createSonareEngineCommandRingBuffer(capacity = 128) {
1975
2604
  const clampedCapacity = Math.max(1, Math.floor(capacity));
1976
2605
  const sharedBuffer = new SharedArrayBuffer(
@@ -2111,51 +2740,82 @@ function engineRingFromSharedBuffer(sharedBuffer, recordBytes, fallbackCapacity)
2111
2740
  capacity
2112
2741
  };
2113
2742
  }
2743
+ function clipPageRequestRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
2744
+ const headerBytes = SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT;
2745
+ const header = new Int32Array(sharedBuffer, 0, SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS);
2746
+ const existingCapacity = Atomics.load(header, 2);
2747
+ const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
2748
+ const minBytes = sonareClipPageRequestRingBufferByteLength(capacity);
2749
+ if (sharedBuffer.byteLength < minBytes) {
2750
+ throw new Error("clipPageRequestSharedBuffer is too small for the requested ring capacity.");
2751
+ }
2752
+ Atomics.store(header, 2, capacity);
2753
+ Atomics.store(header, 3, SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S);
2754
+ return {
2755
+ header,
2756
+ records: new Uint32Array(
2757
+ sharedBuffer,
2758
+ headerBytes,
2759
+ capacity * SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S
2760
+ ),
2761
+ capacity
2762
+ };
2763
+ }
2114
2764
  function recordOffset(index, capacity, recordBytes) {
2115
2765
  return index % capacity * recordBytes;
2116
2766
  }
2117
- function toBigInt64(value, fallback) {
2118
- if (typeof value === "bigint") {
2119
- return value;
2767
+ function toSafeInteger(value, fallback) {
2768
+ const resolved = typeof value === "bigint" ? Number(value) : value;
2769
+ if (resolved === void 0) {
2770
+ return fallback;
2120
2771
  }
2121
- if (typeof value === "number") {
2122
- return BigInt(Math.trunc(value));
2772
+ if (!Number.isSafeInteger(resolved)) {
2773
+ throw new RangeError("64-bit ring values must be safe integers");
2123
2774
  }
2124
- return fallback;
2775
+ return resolved;
2776
+ }
2777
+ function writeInt64Words(view, offset, value) {
2778
+ const integer = toSafeInteger(value, 0);
2779
+ view.setUint32(offset, integer >>> 0, true);
2780
+ view.setInt32(offset + 4, Math.floor(integer / 4294967296), true);
2781
+ }
2782
+ function readInt64Words(view, offset) {
2783
+ return view.getInt32(offset + 4, true) * 4294967296 + view.getUint32(offset, true);
2125
2784
  }
2126
2785
  function writeEngineCommandRecord(view, offset, command) {
2127
2786
  view.setUint32(offset, command.type, true);
2128
2787
  view.setUint32(offset + 4, command.targetId ?? 0, true);
2129
- view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
2788
+ writeInt64Words(view, offset + 8, toSafeInteger(command.sampleTime, -1));
2130
2789
  view.setFloat64(offset + 16, command.argFloat ?? 0, true);
2131
- view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
2790
+ writeInt64Words(view, offset + 24, toSafeInteger(command.argInt, 0));
2132
2791
  }
2133
2792
  function readEngineCommandRecord(view, offset) {
2134
2793
  return {
2135
2794
  type: view.getUint32(offset, true),
2136
2795
  targetId: view.getUint32(offset + 4, true),
2137
- sampleTime: Number(view.getBigInt64(offset + 8, true)),
2796
+ sampleTime: readInt64Words(view, offset + 8),
2138
2797
  argFloat: view.getFloat64(offset + 16, true),
2139
- argInt: Number(view.getBigInt64(offset + 24, true))
2798
+ argInt: readInt64Words(view, offset + 24)
2140
2799
  };
2141
2800
  }
2142
2801
  function writeEngineTelemetryRecord(view, offset, telemetry) {
2143
2802
  view.setUint32(offset, telemetry.type, true);
2144
2803
  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);
2804
+ writeInt64Words(view, offset + 8, telemetry.renderFrame);
2805
+ writeInt64Words(view, offset + 16, telemetry.timelineSample);
2806
+ writeInt64Words(view, offset + 24, telemetry.audibleTimelineSample);
2148
2807
  view.setInt32(offset + 32, telemetry.graphLatencySamplesQ8, true);
2149
2808
  view.setUint32(offset + 36, telemetry.value, true);
2150
- view.setBigInt64(offset + 40, 0n, true);
2809
+ view.setUint32(offset + 40, 0, true);
2810
+ view.setUint32(offset + 44, 0, true);
2151
2811
  }
2152
2812
  function readEngineTelemetryRecord(view, offset) {
2153
2813
  return {
2154
2814
  type: view.getUint32(offset, true),
2155
2815
  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)),
2816
+ renderFrame: readInt64Words(view, offset + 8),
2817
+ timelineSample: readInt64Words(view, offset + 16),
2818
+ audibleTimelineSample: readInt64Words(view, offset + 24),
2159
2819
  graphLatencySamplesQ8: view.getInt32(offset + 32, true),
2160
2820
  value: view.getUint32(offset + 36, true)
2161
2821
  };
@@ -2204,13 +2864,16 @@ function configureCapture(ctx, options) {
2204
2864
  ctx.postSync({ type: "syncCapture", ...config });
2205
2865
  }
2206
2866
  function armRecord(ctx, trackId, enabled) {
2867
+ if (trackId !== 0) {
2868
+ throw new RangeError("Capture is global; armRecord only accepts trackId 0");
2869
+ }
2207
2870
  if (enabled && !ctx.getCaptureConfig()) {
2208
2871
  throw new Error("Capture buffer is not configured");
2209
2872
  }
2210
2873
  ctx.offlineEngine.armCapture(enabled);
2211
- return ctx.realtimeNode.sendCommand({
2874
+ return ctx.sendCommand({
2212
2875
  type: 13 /* ArmRecord */,
2213
- targetId: ctx.resolveTargetId(trackId),
2876
+ targetId: 0,
2214
2877
  sampleTime: -1,
2215
2878
  argInt: enabled ? 1 : 0
2216
2879
  });
@@ -2219,7 +2882,7 @@ function punch(ctx, inPpq, outPpq) {
2219
2882
  const inSample = ctx.offlineEngine.sampleAtPpq(inPpq);
2220
2883
  const outSample = ctx.offlineEngine.sampleAtPpq(outPpq);
2221
2884
  ctx.offlineEngine.setCapturePunch(inSample, outSample, true);
2222
- return ctx.realtimeNode.sendCommand({
2885
+ return ctx.sendCommand({
2223
2886
  type: 14 /* Punch */,
2224
2887
  sampleTime: -1,
2225
2888
  argInt: inSample,
@@ -2245,7 +2908,7 @@ function addClip(ctx, trackId, buffer, startPpq, opts = {}) {
2245
2908
  const clip = {
2246
2909
  ...opts,
2247
2910
  id,
2248
- channels: buffer,
2911
+ ...Array.isArray(buffer) ? { channels: buffer } : { pageProvider: buffer },
2249
2912
  startPpq,
2250
2913
  trackId: ctx.resolveTargetId(trackId)
2251
2914
  };
@@ -2292,6 +2955,12 @@ function syncClipsDelta(ctx, upserts, removeIds) {
2292
2955
  for (const clip of upserts) {
2293
2956
  const prepared = clip.id === void 0 ? clip : preparedById.get(clip.id) ?? clip;
2294
2957
  const channels = prepared.channels;
2958
+ if (!channels && prepared.pageProvider !== void 0) {
2959
+ if (ctx.commitWorkletClipPageProvider(prepared)) {
2960
+ continue;
2961
+ }
2962
+ throw new Error("A pageProvider on SonareEngine must be created by attachOpfsClipStream().");
2963
+ }
2295
2964
  if (prepared.id === void 0 || prepared.warpMode !== "off" || !channels || channels.length === 0 || channels[0].length <= PREBAKED_CLIP_PAGE_THRESHOLD) {
2296
2965
  inlineUpserts.push(prepared);
2297
2966
  continue;
@@ -2495,13 +3164,65 @@ function isEngineSyncMessage(value) {
2495
3164
  if (!isRecord(value) || typeof value.type !== "string") {
2496
3165
  return false;
2497
3166
  }
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";
3167
+ 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
3168
  }
2500
3169
  function isEngineCaptureRequestMessage(value) {
2501
3170
  return isRecord(value) && value.type === "captureRequest" && typeof value.requestId === "number" && (value.op === "status" || value.op === "read" || value.op === "reset");
2502
3171
  }
3172
+ function isCaptureStatus(value) {
3173
+ return isRecord(value) && typeof value.capturedFrames === "number" && Number.isSafeInteger(value.capturedFrames) && value.capturedFrames >= 0 && typeof value.overflowCount === "number" && Number.isSafeInteger(value.overflowCount) && value.overflowCount >= 0 && typeof value.armed === "boolean" && typeof value.punchEnabled === "boolean" && (value.source === "input" || value.source === "output") && typeof value.recordOffsetSamples === "number" && Number.isSafeInteger(value.recordOffsetSamples);
3174
+ }
3175
+ function isCaptureChannel(value) {
3176
+ return value instanceof Float32Array && typeof ArrayBuffer !== "undefined" && value.buffer instanceof ArrayBuffer;
3177
+ }
3178
+ function isCaptureChannels(value) {
3179
+ return Array.isArray(value) && value.every((channel) => isCaptureChannel(channel));
3180
+ }
3181
+ function hasOwn(value, key) {
3182
+ return Object.prototype.hasOwnProperty.call(value, key);
3183
+ }
3184
+ function engineCaptureResponseRequestId(value) {
3185
+ if (!isRecord(value) || value.type !== "captureResponse" || typeof value.requestId !== "number" || !Number.isSafeInteger(value.requestId)) {
3186
+ return void 0;
3187
+ }
3188
+ return value.requestId;
3189
+ }
2503
3190
  function isEngineCaptureResponseMessage(value) {
2504
- return isRecord(value) && value.type === "captureResponse" && typeof value.requestId === "number" && typeof value.ok === "boolean";
3191
+ const requestId = engineCaptureResponseRequestId(value);
3192
+ if (requestId === void 0 || !isRecord(value)) {
3193
+ return false;
3194
+ }
3195
+ if (value.ok === false) {
3196
+ return typeof value.error === "string" && !hasOwn(value, "status") && !hasOwn(value, "channels");
3197
+ }
3198
+ if (value.ok !== true || hasOwn(value, "error")) {
3199
+ return false;
3200
+ }
3201
+ const hasStatus = hasOwn(value, "status");
3202
+ const hasChannels = hasOwn(value, "channels");
3203
+ if (hasStatus && hasChannels) {
3204
+ return false;
3205
+ }
3206
+ if (hasStatus) {
3207
+ return isCaptureStatus(value.status);
3208
+ }
3209
+ if (hasChannels) {
3210
+ return isCaptureChannels(value.channels);
3211
+ }
3212
+ return true;
3213
+ }
3214
+ function isEngineCaptureResponseForOperation(response, op) {
3215
+ if (!response.ok) {
3216
+ return true;
3217
+ }
3218
+ switch (op) {
3219
+ case "status":
3220
+ return "status" in response;
3221
+ case "read":
3222
+ return "channels" in response;
3223
+ case "reset":
3224
+ return !("status" in response) && !("channels" in response);
3225
+ }
2505
3226
  }
2506
3227
  function isEngineTransportRequestMessage(value) {
2507
3228
  return isRecord(value) && value.type === "transportRequest" && typeof value.requestId === "number" && value.op === "state";
@@ -2521,20 +3242,61 @@ function isEngineTelemetryRecord(value) {
2521
3242
  function isExternalMidiBatchMessage(value) {
2522
3243
  return isRecord(value) && value.type === "externalMidi" && Array.isArray(value.events);
2523
3244
  }
3245
+ function isClipPageRequestMessage(value) {
3246
+ return isRecord(value) && value.type === "clipPageRequest" && Array.isArray(value.requests) && value.requests.every(
3247
+ (request) => isRecord(request) && typeof request.clipId === "number" && typeof request.pageIndex === "number"
3248
+ );
3249
+ }
2524
3250
  function isMeterSnapshot(value) {
2525
3251
  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
3252
  }
2527
3253
 
2528
3254
  // src/worklet/engine-node.ts
3255
+ function isFiniteInteger(value) {
3256
+ if (value === void 0) {
3257
+ return true;
3258
+ }
3259
+ return typeof value === "bigint" || Number.isFinite(value) && Number.isSafeInteger(value);
3260
+ }
3261
+ function isTrackMonitorMode(value) {
3262
+ if (value === void 0) {
3263
+ return false;
3264
+ }
3265
+ const mode = typeof value === "bigint" ? Number(value) : value;
3266
+ return typeof mode === "number" && Number.isSafeInteger(mode) && mode >= 0 && mode <= 2;
3267
+ }
3268
+ function isTrackMonitorLaneIndex(value) {
3269
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= 4294967295;
3270
+ }
3271
+ function isValidCommandRecord(command) {
3272
+ const type = Number(command.type);
3273
+ if (!Number.isSafeInteger(type) || type < 0 /* SetParam */ || type > 17 /* SeekMarker */ && type !== 26 /* SetTrackMonitorMode */) {
3274
+ return false;
3275
+ }
3276
+ return (command.targetId === void 0 || Number.isSafeInteger(command.targetId)) && (command.argFloat === void 0 || Number.isFinite(command.argFloat)) && isFiniteInteger(command.argInt) && isFiniteInteger(command.sampleTime) && (type !== 26 /* SetTrackMonitorMode */ || isTrackMonitorLaneIndex(command.targetId) && isTrackMonitorMode(command.argInt));
3277
+ }
3278
+ var AUDIO_WORKLET_RENDER_QUANTUM = 128;
3279
+ function workletBlockSize(blockSize) {
3280
+ const resolved = blockSize ?? AUDIO_WORKLET_RENDER_QUANTUM;
3281
+ if (!Number.isSafeInteger(resolved) || resolved < AUDIO_WORKLET_RENDER_QUANTUM) {
3282
+ throw new RangeError(
3283
+ `blockSize must be an integer of at least ${AUDIO_WORKLET_RENDER_QUANTUM} frames`
3284
+ );
3285
+ }
3286
+ return resolved;
3287
+ }
2529
3288
  var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2530
- constructor(node, capabilities, commandRing, telemetryRing, meterRing, scopeRing) {
3289
+ constructor(node, capabilities, commandRing, telemetryRing, meterRing, scopeRing, clipPageRequestRing, externalMidiRing) {
2531
3290
  this.telemetryReadIndex = 0;
2532
3291
  this.meterReadIndex = 0;
2533
3292
  this.scopeReadIndex = 0;
3293
+ this.clipPageRequestDroppedRead = 0;
2534
3294
  this.telemetryListeners = /* @__PURE__ */ new Set();
2535
3295
  this.meterListeners = /* @__PURE__ */ new Set();
2536
3296
  this.scopeListeners = /* @__PURE__ */ new Set();
2537
3297
  this.midiOutListeners = /* @__PURE__ */ new Set();
3298
+ this.clipPageRequestListeners = /* @__PURE__ */ new Set();
3299
+ this.syncErrorListeners = /* @__PURE__ */ new Set();
2538
3300
  this.captureRequestId = 1;
2539
3301
  this.captureRequests = /* @__PURE__ */ new Map();
2540
3302
  this.transportRequestId = 1;
@@ -2546,6 +3308,8 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2546
3308
  this.telemetryRing = telemetryRing;
2547
3309
  this.meterRing = meterRing;
2548
3310
  this.scopeRing = scopeRing;
3311
+ this.clipPageRequestRing = clipPageRequestRing;
3312
+ this.externalMidiRing = externalMidiRing;
2549
3313
  this.ready = new Promise((resolve, reject) => {
2550
3314
  this.resolveReady = resolve;
2551
3315
  this.rejectReady = reject;
@@ -2554,14 +3318,19 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2554
3318
  this.resolveReady();
2555
3319
  }
2556
3320
  this.node.port.onmessage = (event) => {
2557
- if (isEngineCaptureResponseMessage(event.data)) {
2558
- const pending = this.captureRequests.get(event.data.requestId);
3321
+ const captureRequestId = engineCaptureResponseRequestId(event.data);
3322
+ if (captureRequestId !== void 0) {
3323
+ const pending = this.captureRequests.get(captureRequestId);
2559
3324
  if (pending) {
2560
- this.captureRequests.delete(event.data.requestId);
2561
- if (event.data.ok) {
3325
+ this.captureRequests.delete(captureRequestId);
3326
+ if (!isEngineCaptureResponseMessage(event.data)) {
3327
+ pending.reject(new Error("Malformed capture response."));
3328
+ } else if (!isEngineCaptureResponseForOperation(event.data, pending.op)) {
3329
+ pending.reject(new Error("Capture response does not match request operation."));
3330
+ } else if (event.data.ok) {
2562
3331
  pending.resolve(event.data);
2563
3332
  } else {
2564
- pending.reject(new Error(event.data.error ?? "Capture request failed"));
3333
+ pending.reject(new Error(event.data.error));
2565
3334
  }
2566
3335
  }
2567
3336
  } else if (isEngineTransportResponseMessage(event.data)) {
@@ -2580,6 +3349,17 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2580
3349
  this.emitMeter(event.data);
2581
3350
  } else if (isExternalMidiBatchMessage(event.data)) {
2582
3351
  this.emitMidiOut(event.data.events);
3352
+ } else if (isClipPageRequestMessage(event.data)) {
3353
+ this.emitClipPageRequests(event.data);
3354
+ } else if (isRecord(event.data) && event.data.type === "syncError") {
3355
+ const syncError = {
3356
+ type: "syncError",
3357
+ syncType: String(event.data.syncType),
3358
+ message: String(event.data.message ?? "AudioWorklet sync failed")
3359
+ };
3360
+ for (const listener of this.syncErrorListeners) {
3361
+ listener(syncError);
3362
+ }
2583
3363
  } else if (isRecord(event.data) && event.data.type === "ready") {
2584
3364
  this.resolveReady();
2585
3365
  } else if (isRecord(event.data) && event.data.type === "error") {
@@ -2587,7 +3367,13 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2587
3367
  }
2588
3368
  };
2589
3369
  }
3370
+ /** Subscribes to control-plane sync rejections reported by the worklet. */
3371
+ onSyncError(listener) {
3372
+ this.syncErrorListeners.add(listener);
3373
+ return () => this.syncErrorListeners.delete(listener);
3374
+ }
2590
3375
  static async create(context, options = {}) {
3376
+ const blockSize = workletBlockSize(options.blockSize);
2591
3377
  const processorName = options.processorName ?? "sonare-realtime-engine-processor";
2592
3378
  const moduleUrl = options.moduleUrl;
2593
3379
  if (moduleUrl && context.audioWorklet?.addModule) {
@@ -2618,10 +3404,12 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2618
3404
  const meterRing = mode === "sab" ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128) : void 0;
2619
3405
  const scopeIntervalFrames = Math.max(0, Math.floor(options.scopeIntervalFrames ?? 0));
2620
3406
  const scopeRing = mode === "sab" && scopeIntervalFrames > 0 ? createSonareScopeRingBuffer(options.scopeRingCapacity ?? 64, options.scopeBands ?? 48) : void 0;
3407
+ const clipPageRequestRing = mode === "sab" ? createSonareClipPageRequestRingBuffer(options.clipPageRequestRingCapacity ?? 128) : void 0;
3408
+ const externalMidiRing = mode === "sab" ? createSonareExternalMidiRingBuffer(options.externalMidiRingCapacity ?? 256) : void 0;
2621
3409
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
2622
3410
  const processorOptions = {
2623
3411
  sampleRate: options.sampleRate ?? context.sampleRate,
2624
- blockSize: options.blockSize,
3412
+ blockSize,
2625
3413
  channelCount,
2626
3414
  commandSharedBuffer: commandRing?.sharedBuffer,
2627
3415
  commandRingCapacity: commandRing?.capacity,
@@ -2633,6 +3421,10 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2633
3421
  scopeRingCapacity: scopeRing?.capacity,
2634
3422
  scopeBands: scopeRing?.bands,
2635
3423
  scopeIntervalFrames: scopeRing ? scopeIntervalFrames : void 0,
3424
+ clipPageRequestSharedBuffer: clipPageRequestRing?.sharedBuffer,
3425
+ clipPageRequestRingCapacity: clipPageRequestRing?.capacity,
3426
+ externalMidiSharedBuffer: externalMidiRing?.sharedBuffer,
3427
+ externalMidiRingCapacity: externalMidiRing?.capacity,
2636
3428
  wasmBinary: options.wasmBinary,
2637
3429
  initialSyncMessages: options.initialSyncMessages,
2638
3430
  initialCommands: options.initialCommands
@@ -2652,16 +3444,23 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2652
3444
  sharedArrayBuffer,
2653
3445
  atomics,
2654
3446
  audioWorklet,
3447
+ clipPageRequestsRealtimeSafe: mode === "sab",
3448
+ externalMidiRealtimeSafe: mode === "sab",
2655
3449
  engineAbiVersion: detectedCapabilities?.engineAbiVersion,
2656
3450
  expectedEngineAbiVersion: detectedCapabilities?.expectedEngineAbiVersion,
2657
3451
  abiCompatible: detectedCapabilities?.abiCompatible,
2658
3452
  degradedReason,
2659
- readyMessage: moduleUrl !== void 0 && !options.nodeFactory
3453
+ // A processor posts ready/error irrespective of whether its module was
3454
+ // loaded here or registered by the host beforehand. Waiting in both
3455
+ // cases is the only way to surface a failed worklet-side WASM init.
3456
+ readyMessage: true
2660
3457
  },
2661
3458
  commandRing,
2662
3459
  telemetryRing,
2663
3460
  meterRing,
2664
- scopeRing
3461
+ scopeRing,
3462
+ clipPageRequestRing,
3463
+ externalMidiRing
2665
3464
  );
2666
3465
  }
2667
3466
  play(sampleTime = -1) {
@@ -2678,6 +3477,9 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2678
3477
  });
2679
3478
  }
2680
3479
  seekPpq(ppq, sampleTime = -1) {
3480
+ if (!Number.isFinite(ppq) || !Number.isSafeInteger(sampleTime)) {
3481
+ return false;
3482
+ }
2681
3483
  return this.sendCommand({
2682
3484
  type: 5 /* TransportSeekPpq */,
2683
3485
  sampleTime,
@@ -2685,7 +3487,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2685
3487
  });
2686
3488
  }
2687
3489
  sendCommand(command) {
2688
- if (this.destroyed) {
3490
+ if (this.destroyed || !isValidCommandRecord(command)) {
2689
3491
  return false;
2690
3492
  }
2691
3493
  if (this.commandRing) {
@@ -2696,18 +3498,19 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2696
3498
  }
2697
3499
  requestCaptureStatus() {
2698
3500
  return this.sendCaptureRequest("status").then((response) => {
2699
- if (!response.status) {
3501
+ if (!response.ok || !("status" in response)) {
2700
3502
  throw new Error("Capture status response is missing status.");
2701
3503
  }
2702
3504
  return response.status;
2703
3505
  });
2704
3506
  }
2705
3507
  requestCapturedAudio() {
2706
- return this.sendCaptureRequest("read").then(
2707
- (response) => (response.channels ?? []).map(
2708
- (channel) => channel instanceof Float32Array ? channel : new Float32Array(channel)
2709
- )
2710
- );
3508
+ return this.sendCaptureRequest("read").then((response) => {
3509
+ if (!response.ok || !("channels" in response)) {
3510
+ throw new Error("Capture read response is missing channels.");
3511
+ }
3512
+ return response.channels;
3513
+ });
2711
3514
  }
2712
3515
  requestCaptureReset() {
2713
3516
  return this.sendCaptureRequest("reset").then(() => void 0);
@@ -2759,22 +3562,73 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2759
3562
  }
2760
3563
  return read.scopes;
2761
3564
  }
3565
+ /** Drain lowered MIDI-1 records from the SAB ring on the main thread. */
3566
+ pollMidiOut() {
3567
+ if (!this.externalMidiRing) {
3568
+ return [];
3569
+ }
3570
+ const read = readSonareExternalMidiRingBuffer(this.externalMidiRing);
3571
+ const events = read.events.map((event) => {
3572
+ const bytes = [];
3573
+ for (let index = 0; index < event.byteCount; index++) {
3574
+ bytes.push(event.byteWord >>> 8 * index & 255);
3575
+ }
3576
+ return {
3577
+ destinationId: event.destinationId,
3578
+ renderFrame: event.renderFrame,
3579
+ bytes
3580
+ };
3581
+ });
3582
+ if (events.length > 0) {
3583
+ this.emitMidiOut(events);
3584
+ }
3585
+ return events;
3586
+ }
3587
+ /**
3588
+ * Drains bounded paged-clip misses from the SAB ring and forwards one batch
3589
+ * to subscribers. In postMessage mode the legacy handler remains available,
3590
+ * but that degraded path is not realtime-safe for OPFS streaming.
3591
+ */
3592
+ pollClipPageRequests() {
3593
+ if (!this.clipPageRequestRing) {
3594
+ return void 0;
3595
+ }
3596
+ const read = readSonareClipPageRequestRingBuffer(this.clipPageRequestRing);
3597
+ const dropped = read.dropped - this.clipPageRequestDroppedRead >>> 0;
3598
+ this.clipPageRequestDroppedRead = read.dropped;
3599
+ if (read.requests.length === 0 && dropped === 0) {
3600
+ return void 0;
3601
+ }
3602
+ const message = {
3603
+ type: "clipPageRequest",
3604
+ requests: read.requests,
3605
+ ...dropped > 0 ? { dropped } : {}
3606
+ };
3607
+ this.emitClipPageRequests(message);
3608
+ return message;
3609
+ }
2762
3610
  onTelemetry(callback) {
2763
3611
  this.telemetryListeners.add(callback);
3612
+ this.startRingPolling();
2764
3613
  return () => {
2765
3614
  this.telemetryListeners.delete(callback);
3615
+ this.stopRingPollingIfUnused();
2766
3616
  };
2767
3617
  }
2768
3618
  onMeter(callback) {
2769
3619
  this.meterListeners.add(callback);
3620
+ this.startRingPolling();
2770
3621
  return () => {
2771
3622
  this.meterListeners.delete(callback);
3623
+ this.stopRingPollingIfUnused();
2772
3624
  };
2773
3625
  }
2774
3626
  onScope(callback) {
2775
3627
  this.scopeListeners.add(callback);
3628
+ this.startRingPolling();
2776
3629
  return () => {
2777
3630
  this.scopeListeners.delete(callback);
3631
+ this.stopRingPollingIfUnused();
2778
3632
  };
2779
3633
  }
2780
3634
  /**
@@ -2784,8 +3638,20 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2784
3638
  */
2785
3639
  onMidiOut(callback) {
2786
3640
  this.midiOutListeners.add(callback);
3641
+ this.startRingPolling();
2787
3642
  return () => {
2788
3643
  this.midiOutListeners.delete(callback);
3644
+ this.stopRingPollingIfUnused();
3645
+ };
3646
+ }
3647
+ /**
3648
+ * Subscribe to bounded batches of paged-clip misses from the worklet. The
3649
+ * callback runs on the main thread and may initiate asynchronous OPFS I/O.
3650
+ */
3651
+ onClipPageRequests(callback) {
3652
+ this.clipPageRequestListeners.add(callback);
3653
+ return () => {
3654
+ this.clipPageRequestListeners.delete(callback);
2789
3655
  };
2790
3656
  }
2791
3657
  destroy() {
@@ -2793,7 +3659,11 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2793
3659
  return;
2794
3660
  }
2795
3661
  this.destroyed = true;
2796
- this.node.port.postMessage({ type: 3 /* TransportStop */, sampleTime: -1 });
3662
+ if (this.ringPollTimer !== void 0) {
3663
+ clearInterval(this.ringPollTimer);
3664
+ this.ringPollTimer = void 0;
3665
+ }
3666
+ this.node.port.postMessage({ type: "destroy" });
2797
3667
  this.node.disconnect();
2798
3668
  for (const pending of this.captureRequests.values()) {
2799
3669
  pending.reject(new Error("Realtime engine node is destroyed."));
@@ -2807,12 +3677,34 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2807
3677
  this.meterListeners.clear();
2808
3678
  this.scopeListeners.clear();
2809
3679
  this.midiOutListeners.clear();
3680
+ this.clipPageRequestListeners.clear();
2810
3681
  }
2811
3682
  emitTelemetry(telemetry) {
2812
3683
  for (const listener of this.telemetryListeners) {
2813
3684
  listener(telemetry);
2814
3685
  }
2815
3686
  }
3687
+ startRingPolling() {
3688
+ if (this.ringPollTimer !== void 0 || !this.telemetryRing && !this.meterRing && !this.scopeRing && !this.externalMidiRing) {
3689
+ return;
3690
+ }
3691
+ const poll = () => {
3692
+ if (!this.destroyed) {
3693
+ this.pollTelemetry();
3694
+ this.pollMeters();
3695
+ this.pollScope();
3696
+ this.pollMidiOut();
3697
+ }
3698
+ };
3699
+ poll();
3700
+ this.ringPollTimer = setInterval(poll, 16);
3701
+ }
3702
+ stopRingPollingIfUnused() {
3703
+ if (this.ringPollTimer !== void 0 && this.telemetryListeners.size === 0 && this.meterListeners.size === 0 && this.scopeListeners.size === 0 && this.midiOutListeners.size === 0) {
3704
+ clearInterval(this.ringPollTimer);
3705
+ this.ringPollTimer = void 0;
3706
+ }
3707
+ }
2816
3708
  emitMeter(meter) {
2817
3709
  for (const listener of this.meterListeners) {
2818
3710
  listener(meter);
@@ -2823,6 +3715,11 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2823
3715
  listener(events);
2824
3716
  }
2825
3717
  }
3718
+ emitClipPageRequests(message) {
3719
+ for (const listener of this.clipPageRequestListeners) {
3720
+ listener(message);
3721
+ }
3722
+ }
2826
3723
  emitScope(scope) {
2827
3724
  for (const listener of this.scopeListeners) {
2828
3725
  listener(scope);
@@ -2833,10 +3730,17 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2833
3730
  return Promise.reject(new Error("Realtime engine node is destroyed."));
2834
3731
  }
2835
3732
  const requestId = this.captureRequestId++;
3733
+ let rejectRequest;
2836
3734
  const promise = new Promise((resolve, reject) => {
2837
- this.captureRequests.set(requestId, { resolve, reject });
3735
+ rejectRequest = reject;
3736
+ this.captureRequests.set(requestId, { op, resolve, reject });
2838
3737
  });
2839
- this.node.port.postMessage({ type: "captureRequest", requestId, op });
3738
+ try {
3739
+ this.node.port.postMessage({ type: "captureRequest", requestId, op });
3740
+ } catch (error) {
3741
+ this.captureRequests.delete(requestId);
3742
+ rejectRequest(error);
3743
+ }
2840
3744
  return promise;
2841
3745
  }
2842
3746
  sendTransportRequest() {
@@ -2856,7 +3760,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
2856
3760
  function setParam(ctx, nodeId, param, value) {
2857
3761
  const paramId = ctx.resolveParamId(nodeId, param);
2858
3762
  ctx.offlineEngine.setParameter(paramId, value);
2859
- return ctx.realtimeNode.sendCommand({
3763
+ return ctx.sendCommand({
2860
3764
  type: 0 /* SetParam */,
2861
3765
  targetId: paramId,
2862
3766
  sampleTime: -1,
@@ -2866,13 +3770,28 @@ function setParam(ctx, nodeId, param, value) {
2866
3770
  function setSoloMute(ctx, target, solo, mute) {
2867
3771
  const laneIndex = ctx.ensureTrackLane(target);
2868
3772
  ctx.offlineEngine.setSoloMute(laneIndex, solo, mute);
2869
- return ctx.realtimeNode.sendCommand({
3773
+ return ctx.sendCommand({
2870
3774
  type: 10 /* SetSoloMute */,
2871
3775
  targetId: laneIndex,
2872
3776
  sampleTime: -1,
2873
3777
  argInt: (mute ? 1 : 0) | (solo ? 2 : 0)
2874
3778
  });
2875
3779
  }
3780
+ function setTrackMonitorMode(ctx, target, mode, renderFrame = -1) {
3781
+ const modeOrdinal = trackMonitorModeCode(mode);
3782
+ const laneIndex = ctx.ensureTrackLane(target);
3783
+ ctx.offlineEngine.setTrackMonitorMode(
3784
+ laneIndex,
3785
+ modeOrdinal,
3786
+ renderFrame
3787
+ );
3788
+ return ctx.sendCommand({
3789
+ type: 26 /* SetTrackMonitorMode */,
3790
+ targetId: laneIndex,
3791
+ sampleTime: renderFrame,
3792
+ argInt: modeOrdinal
3793
+ });
3794
+ }
2876
3795
  function automationParamId(ctx, target, kind) {
2877
3796
  const paramKind = kind === "pan" ? ENGINE_MIXER_PARAM_PAN : ENGINE_MIXER_PARAM_FADER_DB;
2878
3797
  if (target === "master") {
@@ -2908,6 +3827,15 @@ function listParameters(ctx) {
2908
3827
  }
2909
3828
  return parameters;
2910
3829
  }
3830
+ function addParameter(ctx, info) {
3831
+ ctx.offlineEngine.addParameter(info);
3832
+ ctx.postSync({ type: "syncParameters", parameters: listParameters(ctx) });
3833
+ }
3834
+ function clearParameters(ctx) {
3835
+ ctx.offlineEngine.clearParameters();
3836
+ ctx.automationLanes.clear();
3837
+ ctx.postSync({ type: "syncParameters", parameters: [] });
3838
+ }
2911
3839
 
2912
3840
  // src/worklet/engine-strips.ts
2913
3841
  function trackIdFor(ctx, target) {
@@ -3022,6 +3950,11 @@ function pushMidiCc(ctx, trackId, group, channel, controller, value, renderFrame
3022
3950
  renderFrame
3023
3951
  });
3024
3952
  }
3953
+ function pushMidiUmp(ctx, trackId, word0, renderFrame) {
3954
+ const destinationId = ctx.resolveTargetId(trackId);
3955
+ ctx.offlineEngine.pushMidiUmp(destinationId, word0, renderFrame);
3956
+ ctx.postSync({ type: "syncMidiUmp", destinationId, word0, renderFrame });
3957
+ }
3025
3958
  function setBuiltinInstrument(ctx, trackId, config) {
3026
3959
  const destinationId = ctx.resolveTargetId(trackId);
3027
3960
  ctx.offlineEngine.setBuiltinInstrument(config, destinationId);
@@ -3082,11 +4015,6 @@ function setTempo(ctx, bpm) {
3082
4015
  ctx.setTempoSegments([{ startPpq: 0, bpm }]);
3083
4016
  ctx.offlineEngine.setTempo(bpm);
3084
4017
  postTempoSync(ctx);
3085
- ctx.realtimeNode.sendCommand({
3086
- type: 6 /* SetTempoMap */,
3087
- sampleTime: -1,
3088
- argFloat: bpm
3089
- });
3090
4018
  }
3091
4019
  function setTempoSegments(ctx, segments) {
3092
4020
  const copied = segments.map((segment) => ({ ...segment }));
@@ -3113,7 +4041,7 @@ function setTimeSignatureSegments(ctx, segments) {
3113
4041
  }
3114
4042
  function setLoop(ctx, startPpq, endPpq, enabled = true) {
3115
4043
  ctx.offlineEngine.setLoop(startPpq, endPpq, enabled);
3116
- return ctx.realtimeNode.sendCommand({
4044
+ return ctx.sendCommand({
3117
4045
  type: 7 /* SetLoop */,
3118
4046
  targetId: enabled ? 1 : 0,
3119
4047
  sampleTime: -1,
@@ -3134,6 +4062,19 @@ function cachedTransportState(ctx) {
3134
4062
  }
3135
4063
 
3136
4064
  // src/worklet/engine.ts
4065
+ var MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS = 256;
4066
+ function transferableAudioBuffers(channels) {
4067
+ const transfers = [];
4068
+ const seen = /* @__PURE__ */ new Set();
4069
+ for (const channel of channels) {
4070
+ const buffer = channel.buffer;
4071
+ if (buffer instanceof ArrayBuffer && !seen.has(buffer)) {
4072
+ seen.add(buffer);
4073
+ transfers.push(buffer);
4074
+ }
4075
+ }
4076
+ return transfers;
4077
+ }
3137
4078
  var SonareEngine = class _SonareEngine {
3138
4079
  constructor(context, realtimeNode, offlineEngine, sampleRate, offlineBlockSize, offlineChannelCount) {
3139
4080
  this.automationLanes = /* @__PURE__ */ new Map();
@@ -3157,6 +4098,11 @@ var SonareEngine = class _SonareEngine {
3157
4098
  this.nextMarkerId = 1;
3158
4099
  this.transportPlaying = false;
3159
4100
  this.pendingInstrumentSync = [];
4101
+ // One latest frontier per clip is sufficient: ClipPageStreamer expands it to
4102
+ // the bounded read window. A Map both coalesces repeated page misses and
4103
+ // places a hard cap on work queued while OPFS I/O is stalled.
4104
+ this.workletClipPageRequests = /* @__PURE__ */ new Map();
4105
+ this.workletPageProviderClipIds = /* @__PURE__ */ new Map();
3160
4106
  this.destroyed = false;
3161
4107
  this.context = context;
3162
4108
  this.realtimeNode = realtimeNode;
@@ -3166,10 +4112,17 @@ var SonareEngine = class _SonareEngine {
3166
4112
  this.sampleRate = sampleRate;
3167
4113
  this.offlineBlockSize = offlineBlockSize;
3168
4114
  this.offlineChannelCount = offlineChannelCount;
4115
+ this.unsubscribeWorkletClipRequests = this.realtimeNode.onClipPageRequests((message) => {
4116
+ for (const request of message.requests) {
4117
+ this.enqueueWorkletClipPageRequest(request);
4118
+ }
4119
+ this.pumpWorkletClipPages();
4120
+ });
3169
4121
  this.transport = buildTransportFacade({
3170
4122
  sampleRate: this.sampleRate,
3171
4123
  realtimeNode: this.realtimeNode,
3172
4124
  offlineEngine: this.offlineEngine,
4125
+ flushOfflineMirror: () => this.flushOfflineMirror(),
3173
4126
  setTransportPlaying: (playing) => {
3174
4127
  this.transportPlaying = playing;
3175
4128
  },
@@ -3187,8 +4140,14 @@ var SonareEngine = class _SonareEngine {
3187
4140
  Math.floor(options.offlineChannelCount ?? options.channelCount ?? 2)
3188
4141
  );
3189
4142
  const realtimeNode = await SonareRealtimeEngineNode.create(context, options);
4143
+ try {
4144
+ await realtimeNode.ready;
4145
+ } catch (error) {
4146
+ realtimeNode.destroy();
4147
+ throw error;
4148
+ }
3190
4149
  const offlineEngine = options.offlineEngine ?? new RealtimeEngine(sampleRate, blockSize);
3191
- return new _SonareEngine(
4150
+ const engine = new _SonareEngine(
3192
4151
  context,
3193
4152
  realtimeNode,
3194
4153
  offlineEngine,
@@ -3196,6 +4155,8 @@ var SonareEngine = class _SonareEngine {
3196
4155
  blockSize,
3197
4156
  channelCount
3198
4157
  );
4158
+ engine.syncParameters();
4159
+ return engine;
3199
4160
  }
3200
4161
  async suspend() {
3201
4162
  if (this.destroyed) {
@@ -3333,9 +4294,21 @@ var SonareEngine = class _SonareEngine {
3333
4294
  listParameters() {
3334
4295
  return listParameters(this.parameterContext);
3335
4296
  }
4297
+ /** Registers a custom parameter on the offline mirror and worklet engine. */
4298
+ addParameter(info) {
4299
+ addParameter(this.parameterContext, info);
4300
+ }
4301
+ /** Clears custom parameters and their automation lanes on both engines. */
4302
+ clearParameters() {
4303
+ clearParameters(this.parameterContext);
4304
+ }
3336
4305
  setSoloMute(target, solo, mute) {
3337
4306
  return setSoloMute(this.parameterContext, target, solo, mute);
3338
4307
  }
4308
+ /** Queues a per-track PFL/AFL monitor tap mode change. */
4309
+ setTrackMonitorMode(target, mode, renderFrame = -1) {
4310
+ return setTrackMonitorMode(this.parameterContext, target, mode, renderFrame);
4311
+ }
3339
4312
  setStripGain(target, db) {
3340
4313
  return this.sendSmoothedParam(this.stripParamId(target, ENGINE_MIXER_PARAM_FADER_DB), db);
3341
4314
  }
@@ -3468,6 +4441,76 @@ var SonareEngine = class _SonareEngine {
3468
4441
  setMasterChain(sceneJson) {
3469
4442
  this.setMasterStripJson(sceneJson);
3470
4443
  }
4444
+ /**
4445
+ * Creates and primes an OPFS-backed page provider for this live worklet
4446
+ * engine. Pass the returned `provider` to {@link addClip} in place of a
4447
+ * `Float32Array[]`; the `clipId` in `options` must equal that clip's explicit
4448
+ * `opts.id`. Subsequent cache misses are fetched on the main thread and
4449
+ * supplied back to the worklet through its bounded pull protocol.
4450
+ */
4451
+ async attachOpfsClipStream(options) {
4452
+ if (this.destroyed) {
4453
+ throw new Error("SonareEngine is destroyed.");
4454
+ }
4455
+ if (!this.capabilities.clipPageRequestsRealtimeSafe) {
4456
+ throw new Error(
4457
+ "OPFS clip streaming requires SharedArrayBuffer clip-page requests; the postMessage fallback is not realtime-safe."
4458
+ );
4459
+ }
4460
+ const { clipId, primePages = 1, ...providerOptions } = options;
4461
+ if ([...this.workletPageProviderClipIds.values()].includes(clipId)) {
4462
+ throw new Error(`An OPFS stream is already attached for clip ${clipId}.`);
4463
+ }
4464
+ const streamer = this.ensureWorkletClipStreamer();
4465
+ this.postSync({
4466
+ type: "syncClipPageProvider",
4467
+ clipId,
4468
+ numChannels: providerOptions.numChannels,
4469
+ numSamples: providerOptions.numSamples,
4470
+ pageFrames: providerOptions.pageFrames
4471
+ });
4472
+ let binding;
4473
+ binding = createOpfsClipPageProvider(this.offlineEngine, {
4474
+ ...providerOptions,
4475
+ onPageSupplied: (pageIndex, channels) => {
4476
+ this.postSync(
4477
+ { type: "syncClipPage", clipId, pageIndex, channels },
4478
+ transferableAudioBuffers(channels)
4479
+ );
4480
+ },
4481
+ onPageCleared: (pageIndex) => {
4482
+ this.postSync({ type: "syncClipPageClear", clipId, pageIndex });
4483
+ },
4484
+ onClose: () => {
4485
+ this.workletPageProviderClipIds.delete(binding.provider.id);
4486
+ this.postSync({ type: "syncClipPageDestroy", clipId });
4487
+ }
4488
+ });
4489
+ this.workletPageProviderClipIds.set(binding.provider.id, clipId);
4490
+ const lastPage = Math.ceil(providerOptions.numSamples / providerOptions.pageFrames) - 1;
4491
+ const primed = [];
4492
+ try {
4493
+ for (let page = 0; page < primePages && page <= lastPage; ++page) {
4494
+ if (await binding.supplyPage(page)) {
4495
+ primed.push(page);
4496
+ }
4497
+ }
4498
+ streamer.addSource(
4499
+ {
4500
+ clipId,
4501
+ binding,
4502
+ pageFrames: providerOptions.pageFrames,
4503
+ numSamples: providerOptions.numSamples
4504
+ },
4505
+ primed
4506
+ );
4507
+ this.startWorkletClipPagePolling();
4508
+ } catch (error) {
4509
+ binding.close();
4510
+ throw error;
4511
+ }
4512
+ return { binding, provider: binding.provider };
4513
+ }
3471
4514
  addClip(trackId, buffer, startPpq, opts = {}) {
3472
4515
  return addClip(this.clipContext, trackId, buffer, startPpq, opts);
3473
4516
  }
@@ -3529,9 +4572,59 @@ var SonareEngine = class _SonareEngine {
3529
4572
  pushMidiCc(trackId, group, channel, controller, value, renderFrame = -1) {
3530
4573
  pushMidiCc(this.stripContext, trackId, group, channel, controller, value, renderFrame);
3531
4574
  }
4575
+ pushMidiUmp(trackId, word0, renderFrame = -1) {
4576
+ pushMidiUmp(this.stripContext, trackId, word0, renderFrame);
4577
+ }
3532
4578
  pushMidiSysex(trackId, data, renderFrame = -1) {
3533
4579
  pushMidiSysex(this.stripContext, trackId, data, renderFrame);
3534
4580
  }
4581
+ bindMidiCc(channel, controller, paramId, options = {}) {
4582
+ const minValue = options.minValue ?? 0;
4583
+ const maxValue = options.maxValue ?? 1;
4584
+ this.offlineEngine.bindMidiCc(channel, controller, paramId, { minValue, maxValue });
4585
+ this.postSync({ type: "syncMidiCcBinding", channel, controller, paramId, minValue, maxValue });
4586
+ }
4587
+ setMidiInputSource(destinationId = 0) {
4588
+ this.offlineEngine.setMidiInputSource(destinationId);
4589
+ this.postSync({ type: "syncMidiInputSource", destinationId });
4590
+ }
4591
+ clearMidiInputSource() {
4592
+ this.offlineEngine.clearMidiInputSource();
4593
+ this.postSync({ type: "syncClearMidiInputSource" });
4594
+ }
4595
+ pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples = 0) {
4596
+ this.offlineEngine.pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
4597
+ this.postSync({
4598
+ type: "syncMidiInputNoteOn",
4599
+ group,
4600
+ channel,
4601
+ data0: note,
4602
+ data1: velocity,
4603
+ portTimeSamples
4604
+ });
4605
+ }
4606
+ pushMidiInputNoteOff(group, channel, note, velocity = 0, portTimeSamples = 0) {
4607
+ this.offlineEngine.pushMidiInputNoteOff(group, channel, note, velocity, portTimeSamples);
4608
+ this.postSync({
4609
+ type: "syncMidiInputNoteOff",
4610
+ group,
4611
+ channel,
4612
+ data0: note,
4613
+ data1: velocity,
4614
+ portTimeSamples
4615
+ });
4616
+ }
4617
+ pushMidiInputCc(group, channel, controller, value, portTimeSamples = 0) {
4618
+ this.offlineEngine.pushMidiInputCc(group, channel, controller, value, portTimeSamples);
4619
+ this.postSync({
4620
+ type: "syncMidiInputCc",
4621
+ group,
4622
+ channel,
4623
+ data0: controller,
4624
+ data1: value,
4625
+ portTimeSamples
4626
+ });
4627
+ }
3535
4628
  pushMidiPanic(renderFrame = -1) {
3536
4629
  this.offlineEngine.pushMidiPanic(renderFrame);
3537
4630
  this.postSync({ type: "syncMidiPanic", renderFrame });
@@ -3539,6 +4632,10 @@ var SonareEngine = class _SonareEngine {
3539
4632
  configureCapture(options) {
3540
4633
  configureCapture(this.captureContext, options);
3541
4634
  }
4635
+ /**
4636
+ * Arms the engine-global capture path. `trackId` is retained for source
4637
+ * compatibility and must be `0`; per-track capture is not implemented.
4638
+ */
3542
4639
  armRecord(trackId, enabled) {
3543
4640
  return armRecord(this.captureContext, trackId, enabled);
3544
4641
  }
@@ -3557,7 +4654,7 @@ var SonareEngine = class _SonareEngine {
3557
4654
  setMetronome(opts) {
3558
4655
  this.offlineEngine.setMetronome(opts);
3559
4656
  this.postSync({ type: "syncMetronome", config: opts });
3560
- this.realtimeNode.sendCommand({
4657
+ this.sendMirroredCommand({
3561
4658
  type: 15 /* SetMetronome */,
3562
4659
  sampleTime: -1,
3563
4660
  argInt: opts.enabled ? 1 : 0
@@ -3627,12 +4724,101 @@ var SonareEngine = class _SonareEngine {
3627
4724
  if (this.destroyed) {
3628
4725
  return;
3629
4726
  }
4727
+ this.unsubscribeWorkletClipRequests?.();
4728
+ this.unsubscribeWorkletClipRequests = void 0;
4729
+ this.workletClipStreamer?.close();
4730
+ this.workletClipStreamer = void 0;
4731
+ if (this.workletClipPagePollTimer !== void 0) {
4732
+ clearInterval(this.workletClipPagePollTimer);
4733
+ this.workletClipPagePollTimer = void 0;
4734
+ }
4735
+ this.workletClipPageRequests.clear();
3630
4736
  this.destroyed = true;
3631
4737
  this.transport.stop();
3632
4738
  this.realtimeNode.pollTelemetry();
3633
4739
  this.realtimeNode.destroy();
3634
4740
  this.offlineEngine.destroy();
3635
4741
  }
4742
+ ensureWorkletClipStreamer() {
4743
+ if (!this.workletClipStreamer) {
4744
+ this.workletClipStreamer = new ClipPageStreamer({
4745
+ popClipPageRequest: () => this.popWorkletClipPageRequest()
4746
+ });
4747
+ }
4748
+ return this.workletClipStreamer;
4749
+ }
4750
+ /**
4751
+ * SAB requests have no postMessage wake-up by design. Polling on the main
4752
+ * thread is therefore intentionally outside the audio callback; 8 ms keeps
4753
+ * the bounded OPFS prefetch frontier responsive without adding worklet GC.
4754
+ */
4755
+ startWorkletClipPagePolling() {
4756
+ if (this.workletClipPagePollTimer !== void 0) {
4757
+ return;
4758
+ }
4759
+ const poll = () => {
4760
+ if (!this.destroyed) {
4761
+ this.realtimeNode.pollClipPageRequests();
4762
+ }
4763
+ };
4764
+ poll();
4765
+ this.workletClipPagePollTimer = setInterval(poll, 8);
4766
+ }
4767
+ pumpWorkletClipPages() {
4768
+ const streamer = this.workletClipStreamer;
4769
+ if (!streamer || this.workletClipPump) {
4770
+ return;
4771
+ }
4772
+ this.workletClipPump = streamer.pump().catch((error) => {
4773
+ console.error("Sonare OPFS clip-page supply failed:", error);
4774
+ }).finally(() => {
4775
+ this.workletClipPump = void 0;
4776
+ if (this.workletClipPageRequests.size > 0) {
4777
+ this.pumpWorkletClipPages();
4778
+ }
4779
+ });
4780
+ }
4781
+ enqueueWorkletClipPageRequest(request) {
4782
+ if (!Number.isInteger(request.clipId) || request.clipId < 0 || !Number.isInteger(request.pageIndex) || (request.pageIndex ?? -1) < 0) {
4783
+ return;
4784
+ }
4785
+ this.workletClipPageRequests.delete(request.clipId);
4786
+ if (this.workletClipPageRequests.size >= MAX_PENDING_WORKLET_CLIP_PAGE_REQUESTS) {
4787
+ const oldest = this.workletClipPageRequests.keys().next().value;
4788
+ if (oldest !== void 0) {
4789
+ this.workletClipPageRequests.delete(oldest);
4790
+ }
4791
+ }
4792
+ this.workletClipPageRequests.set(request.clipId, request);
4793
+ }
4794
+ popWorkletClipPageRequest() {
4795
+ const entry = this.workletClipPageRequests.entries().next().value;
4796
+ if (!entry) {
4797
+ return null;
4798
+ }
4799
+ const [clipId, request] = entry;
4800
+ this.workletClipPageRequests.delete(clipId);
4801
+ return request;
4802
+ }
4803
+ commitWorkletClipPageProvider(clip) {
4804
+ const providerId = typeof clip.pageProvider === "object" && clip.pageProvider !== null ? clip.pageProvider.id : clip.pageProvider;
4805
+ if (providerId === void 0) {
4806
+ return false;
4807
+ }
4808
+ const clipId = this.workletPageProviderClipIds.get(providerId);
4809
+ if (clipId === void 0) {
4810
+ return false;
4811
+ }
4812
+ if (clip.id !== clipId) {
4813
+ throw new Error(`OPFS stream clipId ${clipId} must match addClip(..., { id: ${clipId} }).`);
4814
+ }
4815
+ this.postSync({
4816
+ type: "syncClipPageCommit",
4817
+ clipId,
4818
+ clip: { ...clip, channels: void 0, pageProvider: void 0 }
4819
+ });
4820
+ return true;
4821
+ }
3636
4822
  mixerLanes() {
3637
4823
  return mixerLanes(this.mixerContext);
3638
4824
  }
@@ -3665,12 +4851,27 @@ var SonareEngine = class _SonareEngine {
3665
4851
  if (this.destroyed) {
3666
4852
  return;
3667
4853
  }
3668
- if (transfer && transfer.length > 0) {
3669
- this.realtimeNode.node.port.postMessage(message, transfer);
3670
- } else {
3671
- this.realtimeNode.node.port.postMessage(message);
4854
+ try {
4855
+ if (transfer && transfer.length > 0) {
4856
+ this.realtimeNode.node.port.postMessage(message, transfer);
4857
+ } else {
4858
+ this.realtimeNode.node.port.postMessage(message);
4859
+ }
4860
+ } finally {
4861
+ this.flushOfflineMirror();
3672
4862
  }
3673
4863
  }
4864
+ // The offline engine is a control-thread mirror. It has no render loop to
4865
+ // drain its command ring, so drain it after each control operation. Never
4866
+ // call this from the worklet/audio path: it is intentionally control-only.
4867
+ flushOfflineMirror() {
4868
+ this.offlineEngine.flushControlCommands();
4869
+ }
4870
+ sendMirroredCommand(command) {
4871
+ const accepted = this.realtimeNode.sendCommand(command);
4872
+ this.flushOfflineMirror();
4873
+ return accepted;
4874
+ }
3674
4875
  // Collaborator surface handed to the mixer/routing free functions so they can
3675
4876
  // mutate the routing stores (held by reference), mirror into the offline
3676
4877
  // engine, post mixer-sync messages, and declare lanes/buses without a
@@ -3725,13 +4926,13 @@ var SonareEngine = class _SonareEngine {
3725
4926
  return {
3726
4927
  offlineEngine: this.offlineEngine,
3727
4928
  realtimeNode: this.realtimeNode,
4929
+ sendCommand: (command) => this.sendMirroredCommand(command),
3728
4930
  offlineChannelCount: this.offlineChannelCount,
3729
4931
  postSync: (message) => this.postSync(message),
3730
4932
  getCaptureConfig: () => this.captureConfig,
3731
4933
  setCaptureConfig: (config) => {
3732
4934
  this.captureConfig = config;
3733
- },
3734
- resolveTargetId: (target) => this.resolveTargetId(target)
4935
+ }
3735
4936
  };
3736
4937
  }
3737
4938
  // Collaborator surface handed to the parameter / automation-id resolution
@@ -3741,7 +4942,9 @@ var SonareEngine = class _SonareEngine {
3741
4942
  get parameterContext() {
3742
4943
  return {
3743
4944
  offlineEngine: this.offlineEngine,
3744
- realtimeNode: this.realtimeNode,
4945
+ sendCommand: (command) => this.sendMirroredCommand(command),
4946
+ postSync: (message) => this.postSync(message),
4947
+ automationLanes: this.automationLanes,
3745
4948
  trackLaneIds: this.trackLaneIds,
3746
4949
  resolveParamId: (nodeId, param) => this.resolveParamId(nodeId, param),
3747
4950
  ensureTrackLane: (target) => this.ensureTrackLane(target),
@@ -3755,6 +4958,7 @@ var SonareEngine = class _SonareEngine {
3755
4958
  return {
3756
4959
  offlineEngine: this.offlineEngine,
3757
4960
  realtimeNode: this.realtimeNode,
4961
+ sendCommand: (command) => this.sendMirroredCommand(command),
3758
4962
  postSync: (message) => this.postSync(message),
3759
4963
  getTempoBpm: () => this.tempoBpm,
3760
4964
  setTempoBpm: (bpm) => {
@@ -3789,7 +4993,8 @@ var SonareEngine = class _SonareEngine {
3789
4993
  allocateClipId: () => this.nextClipId++,
3790
4994
  postSync: (message, transfer) => this.postSync(message, transfer),
3791
4995
  ensureTrackLane: (target) => this.ensureTrackLane(target),
3792
- resolveTargetId: (target) => this.resolveTargetId(target)
4996
+ resolveTargetId: (target) => this.resolveTargetId(target),
4997
+ commitWorkletClipPageProvider: (clip) => this.commitWorkletClipPageProvider(clip)
3793
4998
  };
3794
4999
  }
3795
5000
  // Collaborator surface handed to the marker free functions so they can mutate
@@ -3804,7 +5009,7 @@ var SonareEngine = class _SonareEngine {
3804
5009
  this.nextMarkerId = value;
3805
5010
  },
3806
5011
  postSync: (message) => this.postSync(message),
3807
- sendCommand: (command) => this.realtimeNode.sendCommand(command),
5012
+ sendCommand: (command) => this.sendMirroredCommand(command),
3808
5013
  setLoop: (startPpq, endPpq, enabled) => this.setLoop(startPpq, endPpq, enabled)
3809
5014
  };
3810
5015
  }
@@ -3820,7 +5025,7 @@ var SonareEngine = class _SonareEngine {
3820
5025
  // sample-accurate smoothed-param command to the realtime runtime.
3821
5026
  sendSmoothedParam(paramId, value) {
3822
5027
  this.offlineEngine.setParameter(paramId, value);
3823
- return this.realtimeNode.sendCommand({
5028
+ return this.sendMirroredCommand({
3824
5029
  type: 1 /* SetParamSmoothed */,
3825
5030
  targetId: paramId,
3826
5031
  sampleTime: -1,
@@ -3830,6 +5035,12 @@ var SonareEngine = class _SonareEngine {
3830
5035
  resolveParamId(nodeId, param) {
3831
5036
  return resolveParamId(this.listParameters(), nodeId, param);
3832
5037
  }
5038
+ syncParameters() {
5039
+ const parameters = this.listParameters();
5040
+ if (parameters.length > 0) {
5041
+ this.postSync({ type: "syncParameters", parameters });
5042
+ }
5043
+ }
3833
5044
  resolveTargetId(target) {
3834
5045
  return resolveTargetId(target);
3835
5046
  }
@@ -3865,21 +5076,38 @@ var SonareEngine = class _SonareEngine {
3865
5076
  var DEFAULT_METRONOME_CONFIG = {
3866
5077
  beatGain: 0.35,
3867
5078
  accentGain: 0.7,
3868
- clickSamples: 0
5079
+ clickSamples: 0,
5080
+ clickSeconds: 0
3869
5081
  };
3870
5082
  function resolveMetronomeConfig(config) {
3871
5083
  const resolved = {
3872
5084
  beatGain: config.beatGain ?? DEFAULT_METRONOME_CONFIG.beatGain,
3873
5085
  accentGain: config.accentGain ?? DEFAULT_METRONOME_CONFIG.accentGain,
3874
- clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples
5086
+ clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples,
5087
+ clickSeconds: config.clickSeconds ?? DEFAULT_METRONOME_CONFIG.clickSeconds
3875
5088
  };
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)) {
5089
+ 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
5090
  throw new RangeError("invalid metronome gains or click length");
3878
5091
  }
3879
5092
  return resolved;
3880
5093
  }
3881
5094
 
3882
5095
  // src/worklet/engine-processor.ts
5096
+ function captureTransferList(channels) {
5097
+ const transfers = [];
5098
+ const seen = /* @__PURE__ */ new Set();
5099
+ for (const channel of channels) {
5100
+ const buffer = channel.buffer;
5101
+ if (!(buffer instanceof ArrayBuffer)) {
5102
+ throw new TypeError("capture response channels must use plain ArrayBuffers");
5103
+ }
5104
+ if (!seen.has(buffer)) {
5105
+ seen.add(buffer);
5106
+ transfers.push(buffer);
5107
+ }
5108
+ }
5109
+ return transfers;
5110
+ }
3883
5111
  var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletProcessor {
3884
5112
  constructor(options = {}, transport) {
3885
5113
  this.closed = false;
@@ -3889,7 +5117,15 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3889
5117
  this.metronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
3890
5118
  this.liveClips = /* @__PURE__ */ new Map();
3891
5119
  this.pagedClipProviders = /* @__PURE__ */ new Map();
5120
+ this.pagedClipPageFrames = /* @__PURE__ */ new Map();
3892
5121
  this.pendingPagedClips = /* @__PURE__ */ new Map();
5122
+ // The worklet drains at most this many distinct page misses per render
5123
+ // quantum. The fixed scalar buffers avoid retaining an unbounded queue on
5124
+ // the audio thread; any remaining native requests stay queued for the next
5125
+ // quantum and render silence meanwhile.
5126
+ this.clipPageRequestClipIds = new Float64Array(64);
5127
+ this.clipPageRequestPageIndices = new Float64Array(64);
5128
+ this.clipPageRequestOverflowReported = 0;
3893
5129
  this.sampleRate = options.sampleRate ?? 48e3;
3894
5130
  this.blockSize = options.blockSize ?? 128;
3895
5131
  this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
@@ -3906,7 +5142,21 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3906
5142
  options.scopeRingCapacity,
3907
5143
  options.scopeBands
3908
5144
  ) : void 0;
3909
- this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
5145
+ this.clipPageRequestRing = options.clipPageRequestSharedBuffer ? clipPageRequestRingFromSharedBuffer(
5146
+ options.clipPageRequestSharedBuffer,
5147
+ options.clipPageRequestRingCapacity
5148
+ ) : void 0;
5149
+ this.externalMidiRing = options.externalMidiSharedBuffer ? externalMidiRingFromSharedBuffer(
5150
+ options.externalMidiSharedBuffer,
5151
+ options.externalMidiRingCapacity
5152
+ ) : void 0;
5153
+ this.engine = new RealtimeEngine(
5154
+ this.sampleRate,
5155
+ this.blockSize,
5156
+ 1024,
5157
+ 1024,
5158
+ this.channelCount
5159
+ );
3910
5160
  this.engine.prepareChannels(this.channelCount, this.blockSize);
3911
5161
  this.channelBuffers = new Array(this.channelCount);
3912
5162
  for (let ch = 0; ch < this.channelCount; ch++) {
@@ -3971,6 +5221,7 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3971
5221
  target.fill(0);
3972
5222
  }
3973
5223
  }
5224
+ this.publishClipPageRequests();
3974
5225
  this.publishTelemetry();
3975
5226
  this.publishMeters();
3976
5227
  this.publishScope();
@@ -3984,7 +5235,7 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3984
5235
  }
3985
5236
  receiveCommand(command) {
3986
5237
  if (!this.closed) {
3987
- this.applyCommand(command);
5238
+ this.safeApplyCommand(command);
3988
5239
  }
3989
5240
  }
3990
5241
  // Applies an out-of-band control-plane sync message on the AudioWorklet
@@ -3995,35 +5246,64 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
3995
5246
  if (this.closed) {
3996
5247
  return;
3997
5248
  }
5249
+ try {
5250
+ this.applySync(message);
5251
+ } catch (error) {
5252
+ this.transport?.postMessage?.({
5253
+ type: "syncError",
5254
+ syncType: message.type,
5255
+ message: error instanceof Error ? error.message : String(error)
5256
+ });
5257
+ }
5258
+ }
5259
+ applySync(message) {
3998
5260
  switch (message.type) {
3999
- case "syncClips":
5261
+ case "destroy":
5262
+ this.destroy();
5263
+ return;
5264
+ case "syncClips": {
5265
+ this.engine.setClips(message.clips);
5266
+ this.clearPagedClipProviders();
4000
5267
  this.liveClips.clear();
4001
5268
  for (const clip of message.clips) {
4002
5269
  if (clip.id !== void 0) {
4003
5270
  this.liveClips.set(clip.id, clip);
4004
5271
  }
4005
5272
  }
4006
- this.engine.setClips(message.clips);
4007
5273
  break;
4008
- case "syncClipsDelta":
5274
+ }
5275
+ case "syncClipsDelta": {
5276
+ const nextClips = new Map(this.liveClips);
4009
5277
  for (const clipId of message.removeIds) {
4010
- this.liveClips.delete(clipId);
5278
+ nextClips.delete(clipId);
4011
5279
  }
4012
5280
  for (const clip of message.upserts) {
4013
5281
  if (clip.id !== void 0) {
4014
- this.liveClips.set(clip.id, clip);
5282
+ nextClips.set(clip.id, clip);
4015
5283
  }
4016
5284
  }
4017
- this.engine.setClips(Array.from(this.liveClips.values()));
5285
+ this.engine.setClips(Array.from(nextClips.values()));
5286
+ for (const clipId of message.removeIds) {
5287
+ this.removePagedClipProvider(clipId);
5288
+ }
5289
+ this.liveClips.clear();
5290
+ for (const [clipId, clip] of nextClips) {
5291
+ this.liveClips.set(clipId, clip);
5292
+ }
4018
5293
  break;
5294
+ }
4019
5295
  case "syncClipPageProvider": {
4020
5296
  const provider = this.engine.createClipPageProvider(
4021
5297
  message.numChannels,
4022
5298
  message.numSamples,
4023
5299
  message.pageFrames
4024
5300
  );
5301
+ this.removePagedClipProvider(message.clipId);
4025
5302
  this.pagedClipProviders.set(message.clipId, provider.id);
4026
- this.pendingPagedClips.set(message.clipId, message.clip);
5303
+ this.pagedClipPageFrames.set(message.clipId, message.pageFrames);
5304
+ if (message.clip) {
5305
+ this.pendingPagedClips.set(message.clipId, message.clip);
5306
+ }
4027
5307
  break;
4028
5308
  }
4029
5309
  case "syncClipPage": {
@@ -4033,16 +5313,34 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4033
5313
  }
4034
5314
  break;
4035
5315
  }
5316
+ case "syncClipPageClear": {
5317
+ const providerId = this.pagedClipProviders.get(message.clipId);
5318
+ if (providerId !== void 0) {
5319
+ this.engine.clearClipPage(providerId, message.pageIndex);
5320
+ }
5321
+ break;
5322
+ }
4036
5323
  case "syncClipPageCommit": {
4037
5324
  const providerId = this.pagedClipProviders.get(message.clipId);
4038
- const clip = this.pendingPagedClips.get(message.clipId);
5325
+ const clip = message.clip ?? this.pendingPagedClips.get(message.clipId);
4039
5326
  if (providerId !== void 0 && clip) {
4040
- this.liveClips.set(message.clipId, { ...clip, pageProvider: providerId });
5327
+ const nextClip = { ...clip, pageProvider: providerId };
5328
+ const nextClips = new Map(this.liveClips);
5329
+ nextClips.set(message.clipId, nextClip);
5330
+ this.engine.setClips(Array.from(nextClips.values()));
5331
+ this.liveClips.set(message.clipId, nextClip);
4041
5332
  this.pendingPagedClips.delete(message.clipId);
4042
- this.engine.setClips(Array.from(this.liveClips.values()));
4043
5333
  }
4044
5334
  break;
4045
5335
  }
5336
+ case "syncClipPageDestroy": {
5337
+ const nextClips = new Map(this.liveClips);
5338
+ nextClips.delete(message.clipId);
5339
+ this.engine.setClips(Array.from(nextClips.values()));
5340
+ this.liveClips.delete(message.clipId);
5341
+ this.removePagedClipProvider(message.clipId);
5342
+ break;
5343
+ }
4046
5344
  case "syncMidiClips":
4047
5345
  this.engine.setMidiClips(message.clips);
4048
5346
  break;
@@ -4050,12 +5348,21 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4050
5348
  this.engine.setMarkers(message.markers);
4051
5349
  break;
4052
5350
  case "syncMetronome":
4053
- this.metronomeConfig = resolveMetronomeConfig(message.config);
4054
- this.engine.setMetronome(message.config);
5351
+ {
5352
+ const config = resolveMetronomeConfig(message.config);
5353
+ this.engine.setMetronome(message.config);
5354
+ this.metronomeConfig = config;
5355
+ }
4055
5356
  break;
4056
5357
  case "syncAutomation":
4057
5358
  this.engine.setAutomationLane(message.paramId, message.points);
4058
5359
  break;
5360
+ case "syncParameters":
5361
+ this.engine.clearParameters();
5362
+ for (const info of message.parameters) {
5363
+ this.engine.addParameter(info);
5364
+ }
5365
+ break;
4059
5366
  case "syncTempo":
4060
5367
  if (message.tempoSegments) {
4061
5368
  this.engine.setTempoSegments(message.tempoSegments);
@@ -4202,6 +5509,9 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4202
5509
  message.renderFrame
4203
5510
  );
4204
5511
  break;
5512
+ case "syncMidiUmp":
5513
+ this.engine.pushMidiUmp(message.destinationId, message.word0, message.renderFrame);
5514
+ break;
4205
5515
  case "syncMidiSysex":
4206
5516
  this.engine.pushMidiSysex(message.destinationId, message.data, message.renderFrame);
4207
5517
  break;
@@ -4214,6 +5524,45 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4214
5524
  case "syncExternalMidiClock":
4215
5525
  this.engine.setExternalMidiClockEnabled(message.enabled);
4216
5526
  break;
5527
+ case "syncMidiInputSource":
5528
+ this.engine.setMidiInputSource(message.destinationId ?? 0);
5529
+ break;
5530
+ case "syncClearMidiInputSource":
5531
+ this.engine.clearMidiInputSource();
5532
+ break;
5533
+ case "syncMidiCcBinding":
5534
+ this.engine.bindMidiCc(message.channel, message.controller, message.paramId, {
5535
+ minValue: message.minValue,
5536
+ maxValue: message.maxValue
5537
+ });
5538
+ break;
5539
+ case "syncMidiInputNoteOn":
5540
+ this.engine.pushMidiInputNoteOn(
5541
+ message.group,
5542
+ message.channel,
5543
+ message.data0,
5544
+ message.data1,
5545
+ message.portTimeSamples
5546
+ );
5547
+ break;
5548
+ case "syncMidiInputNoteOff":
5549
+ this.engine.pushMidiInputNoteOff(
5550
+ message.group,
5551
+ message.channel,
5552
+ message.data0,
5553
+ message.data1,
5554
+ message.portTimeSamples
5555
+ );
5556
+ break;
5557
+ case "syncMidiInputCc":
5558
+ this.engine.pushMidiInputCc(
5559
+ message.group,
5560
+ message.channel,
5561
+ message.data0,
5562
+ message.data1,
5563
+ message.portTimeSamples
5564
+ );
5565
+ break;
4217
5566
  }
4218
5567
  }
4219
5568
  receiveCaptureRequest(message) {
@@ -4239,22 +5588,17 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4239
5588
  return;
4240
5589
  }
4241
5590
  if (message.op === "read") {
4242
- const captured = this.engine.capturedAudio();
4243
- const channels = [];
4244
- for (let ch = 0; ch < captured.length; ch++) {
4245
- const source = captured[ch];
4246
- const copy = [];
4247
- for (let i = 0; i < source.length; i++) {
4248
- copy.push(Number(source[i]));
4249
- }
4250
- channels.push(copy);
4251
- }
4252
- this.transport?.postMessage?.({
4253
- type: "captureResponse",
4254
- requestId: message.requestId,
4255
- ok: true,
4256
- channels
4257
- });
5591
+ const channels = Array.from(this.engine.capturedAudio());
5592
+ const transfer = captureTransferList(channels);
5593
+ this.transport?.postMessage?.(
5594
+ {
5595
+ type: "captureResponse",
5596
+ requestId: message.requestId,
5597
+ ok: true,
5598
+ channels
5599
+ },
5600
+ transfer
5601
+ );
4258
5602
  return;
4259
5603
  }
4260
5604
  this.engine.resetCapture();
@@ -4294,6 +5638,7 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4294
5638
  }
4295
5639
  destroy() {
4296
5640
  if (!this.closed) {
5641
+ this.clearPagedClipProviders();
4297
5642
  this.engine.destroy();
4298
5643
  this.closed = true;
4299
5644
  }
@@ -4307,7 +5652,22 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4307
5652
  if (!command) {
4308
5653
  return;
4309
5654
  }
5655
+ this.safeApplyCommand(command);
5656
+ }
5657
+ }
5658
+ safeApplyCommand(command) {
5659
+ try {
4310
5660
  this.applyCommand(command);
5661
+ } catch {
5662
+ this.publishTelemetryRecord({
5663
+ type: 1 /* Error */,
5664
+ error: 19 /* InvalidCommand */,
5665
+ renderFrame: 0,
5666
+ timelineSample: 0,
5667
+ audibleTimelineSample: 0,
5668
+ graphLatencySamplesQ8: 0,
5669
+ value: Number(command.type)
5670
+ });
4311
5671
  }
4312
5672
  }
4313
5673
  applyCommand(command) {
@@ -4339,9 +5699,6 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4339
5699
  case 5 /* TransportSeekPpq */:
4340
5700
  this.engine.seekPpq(Number(command.argFloat ?? 0), sampleTime);
4341
5701
  break;
4342
- case 6 /* SetTempoMap */:
4343
- this.engine.setTempo(Number(command.argFloat ?? 120));
4344
- break;
4345
5702
  case 7 /* SetLoop */:
4346
5703
  this.engine.setLoop(
4347
5704
  Number(command.argFloat ?? 0),
@@ -4364,7 +5721,8 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4364
5721
  enabled: Boolean(command.argInt),
4365
5722
  beatGain: this.metronomeConfig.beatGain,
4366
5723
  accentGain: this.metronomeConfig.accentGain,
4367
- clickSamples: this.metronomeConfig.clickSamples
5724
+ clickSamples: this.metronomeConfig.clickSamples,
5725
+ clickSeconds: this.metronomeConfig.clickSeconds
4368
5726
  });
4369
5727
  break;
4370
5728
  case 17 /* SeekMarker */:
@@ -4378,6 +5736,19 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4378
5736
  sampleTime
4379
5737
  );
4380
5738
  break;
5739
+ case 26 /* SetTrackMonitorMode */: {
5740
+ const rawMode = command.argInt;
5741
+ const mode = typeof rawMode === "bigint" ? Number(rawMode) : rawMode;
5742
+ if (typeof mode !== "number" || !Number.isSafeInteger(mode) || mode < 0 || mode > 2) {
5743
+ throw new RangeError(`Invalid track monitor mode: ${String(rawMode)}`);
5744
+ }
5745
+ const laneIndex = command.targetId;
5746
+ if (typeof laneIndex !== "number" || !Number.isSafeInteger(laneIndex) || laneIndex < 0 || laneIndex > 4294967295) {
5747
+ throw new RangeError(`Invalid track monitor lane index: ${String(laneIndex)}`);
5748
+ }
5749
+ this.engine.setTrackMonitorMode(laneIndex, mode, sampleTime);
5750
+ break;
5751
+ }
4381
5752
  default:
4382
5753
  this.publishTelemetryRecord({
4383
5754
  type: 1 /* Error */,
@@ -4392,10 +5763,34 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4392
5763
  }
4393
5764
  }
4394
5765
  publishTelemetry() {
5766
+ const ring = this.telemetryRing;
5767
+ if (ring) {
5768
+ for (let count = 0; count < 64 && this.engine.popTelemetryToScratch(); count++) {
5769
+ this.writeTelemetryScratch(ring);
5770
+ }
5771
+ return;
5772
+ }
4395
5773
  for (const item of this.engine.drainTelemetry(64)) {
4396
5774
  this.publishTelemetryRecord(telemetryFromEngine(item));
4397
5775
  }
4398
5776
  }
5777
+ writeTelemetryScratch(ring) {
5778
+ const writeIndex = Atomics.load(ring.header, 0);
5779
+ const offset = writeIndex % ring.capacity * SONARE_ENGINE_TELEMETRY_RECORD_BYTES;
5780
+ ring.view.setUint32(offset, this.engine.telemetryScratchType(), true);
5781
+ ring.view.setUint32(offset + 4, this.engine.telemetryScratchError(), true);
5782
+ writeInt64Words(ring.view, offset + 8, this.engine.telemetryScratchRenderFrame());
5783
+ writeInt64Words(ring.view, offset + 16, this.engine.telemetryScratchTimelineSample());
5784
+ writeInt64Words(ring.view, offset + 24, this.engine.telemetryScratchAudibleTimelineSample());
5785
+ ring.view.setInt32(offset + 32, this.engine.telemetryScratchGraphLatencySamplesQ8(), true);
5786
+ ring.view.setUint32(offset + 36, this.engine.telemetryScratchValue(), true);
5787
+ ring.view.setUint32(offset + 40, 0, true);
5788
+ ring.view.setUint32(offset + 44, 0, true);
5789
+ Atomics.store(ring.header, 0, writeIndex + 1);
5790
+ if (writeIndex + 1 > ring.capacity) {
5791
+ Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
5792
+ }
5793
+ }
4399
5794
  publishTelemetryRecord(record) {
4400
5795
  if (this.telemetryRing) {
4401
5796
  writeSonareEngineTelemetryRingBuffer(this.telemetryRing, record);
@@ -4418,6 +5813,19 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4418
5813
  if (this.meterIntervalFrames <= 0 || !this.transport && !this.meterRing) {
4419
5814
  return;
4420
5815
  }
5816
+ if (this.meterRing) {
5817
+ for (let count = 0; count < 64 && this.engine.popMeterTelemetryToScratch(); count++) {
5818
+ const frame = this.engine.meterScratchRenderFrame();
5819
+ if (frame !== this.lastMeterFrame && frame - this.lastMeterFrame < this.meterIntervalFrames) {
5820
+ continue;
5821
+ }
5822
+ if (frame !== this.lastMeterFrame) {
5823
+ this.lastMeterFrame = frame;
5824
+ }
5825
+ this.writeMeterScratch(this.meterRing);
5826
+ }
5827
+ return;
5828
+ }
4421
5829
  for (const item of this.engine.drainMeterTelemetry(64)) {
4422
5830
  const meter = meterFromEngine(item);
4423
5831
  if (meter.frame !== this.lastMeterFrame && meter.frame - this.lastMeterFrame < this.meterIntervalFrames) {
@@ -4434,6 +5842,18 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4434
5842
  }
4435
5843
  }
4436
5844
  }
5845
+ writeMeterScratch(ring) {
5846
+ const writeIndex = Atomics.load(ring.header, 0);
5847
+ const offset = writeIndex % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
5848
+ const frame = Number(this.engine.meterScratchRenderFrame());
5849
+ ring.records[offset] = encodeFrameLo(frame);
5850
+ ring.records[offset + 1] = encodeFrameHi(frame);
5851
+ ring.records[offset + 2] = this.engine.meterScratchTargetId();
5852
+ for (let field = 0; field < 11; field++) {
5853
+ ring.records[offset + 3 + field] = this.engine.meterScratchValue(field);
5854
+ }
5855
+ Atomics.store(ring.header, 0, writeIndex + 1);
5856
+ }
4437
5857
  writeMeterRing(meter) {
4438
5858
  const ring = this.meterRing;
4439
5859
  if (!ring) {
@@ -4465,45 +5885,160 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
4465
5885
  if (!ring) {
4466
5886
  return;
4467
5887
  }
4468
- for (const item of this.engine.drainScopeTelemetry(64)) {
4469
- this.writeScopeRing(ring, item);
5888
+ for (let count = 0; count < 64 && this.engine.popScopeTelemetryToScratch(); count++) {
5889
+ this.writeScopeScratch(ring);
5890
+ }
5891
+ }
5892
+ writeScopeScratch(ring) {
5893
+ const writeIndex = Atomics.load(ring.header, 0);
5894
+ const base = writeIndex % ring.capacity * ring.recordFloats;
5895
+ const frame = Number(this.engine.scopeScratchRenderFrame());
5896
+ ring.records[base] = encodeFrameLo(frame);
5897
+ ring.records[base + 1] = encodeFrameHi(frame);
5898
+ ring.records[base + 2] = this.engine.scopeScratchTargetId();
5899
+ const bandCount = Math.min(ring.bands, this.engine.scopeScratchBandCount());
5900
+ ring.records[base + 3] = bandCount;
5901
+ const pointCount = Math.min(ring.maxPoints, this.engine.scopeScratchPointCount());
5902
+ ring.records[base + 4] = pointCount;
5903
+ const bandsBase = base + SONARE_SCOPE_RING_RECORD_PREFIX_FLOATS;
5904
+ for (let i = 0; i < bandCount; i++) {
5905
+ ring.records[bandsBase + i] = this.engine.scopeScratchBand(i);
5906
+ }
5907
+ const pointsBase = bandsBase + ring.bands;
5908
+ for (let i = 0; i < pointCount; i++) {
5909
+ ring.records[pointsBase + 2 * i] = this.engine.scopeScratchPointLeft(i);
5910
+ ring.records[pointsBase + 2 * i + 1] = this.engine.scopeScratchPointRight(i);
4470
5911
  }
5912
+ Atomics.store(ring.header, 0, writeIndex + 1);
4471
5913
  }
4472
5914
  // Drains queued external-MIDI events (already lowered to MIDI 1.0 bytes) and
4473
5915
  // forwards them to the main thread for delivery to Web MIDI output ports.
4474
5916
  // One batch per render block; skipped entirely when nothing is queued, so an
4475
5917
  // all-internal project never allocates or posts here.
4476
5918
  publishExternalMidi() {
5919
+ const ring = this.externalMidiRing;
5920
+ if (ring) {
5921
+ for (let count = 0; count < 256 && this.engine.popExternalMidiToScratch(); count++) {
5922
+ pushSonareExternalMidiRingBuffer(
5923
+ ring,
5924
+ this.engine.externalMidiScratchDestinationId(),
5925
+ this.engine.externalMidiScratchRenderFrame(),
5926
+ this.engine.externalMidiScratchByteWord(),
5927
+ this.engine.externalMidiScratchByteCount()
5928
+ );
5929
+ this.engine.consumeExternalMidiScratch();
5930
+ }
5931
+ return;
5932
+ }
4477
5933
  if (!this.transport?.postMessage) {
4478
5934
  return;
4479
5935
  }
5936
+ if (this.engine.externalMidiPendingCount() === 0) {
5937
+ return;
5938
+ }
4480
5939
  const events = this.engine.drainExternalMidi(256);
4481
5940
  if (events.length === 0) {
4482
5941
  return;
4483
5942
  }
4484
5943
  this.transport.postMessage({ type: "externalMidi", events });
4485
5944
  }
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];
5945
+ /**
5946
+ * Forward a bounded batch of native page misses to the main thread. This
5947
+ * deliberately runs after audio rendering: a cache miss is silence for this
5948
+ * block, and OPFS I/O must never delay `process()`.
5949
+ */
5950
+ publishClipPageRequests() {
5951
+ const ring = this.clipPageRequestRing;
5952
+ const transport = this.transport;
5953
+ if (!ring && !transport?.postMessage) {
5954
+ return;
4499
5955
  }
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;
5956
+ let count = 0;
5957
+ let drained = 0;
5958
+ while (drained < this.clipPageRequestClipIds.length) {
5959
+ drained += 1;
5960
+ let clipId;
5961
+ let sample;
5962
+ if (ring) {
5963
+ if (!this.engine.popClipPageRequestToScratch()) {
5964
+ break;
5965
+ }
5966
+ clipId = this.engine.clipPageRequestScratchClipId();
5967
+ sample = this.engine.clipPageRequestScratchSample();
5968
+ } else {
5969
+ const request = this.engine.popClipPageRequest();
5970
+ if (!request) {
5971
+ break;
5972
+ }
5973
+ clipId = request.clipId;
5974
+ sample = request.sample;
5975
+ }
5976
+ const pageFrames = this.pagedClipPageFrames.get(clipId);
5977
+ if (!pageFrames) {
5978
+ continue;
5979
+ }
5980
+ const pageIndex = Math.floor(sample / pageFrames);
5981
+ let duplicate = false;
5982
+ for (let i = 0; i < count; ++i) {
5983
+ if (this.clipPageRequestClipIds[i] === clipId && this.clipPageRequestPageIndices[i] === pageIndex) {
5984
+ duplicate = true;
5985
+ break;
5986
+ }
5987
+ }
5988
+ if (!duplicate) {
5989
+ this.clipPageRequestClipIds[count] = clipId;
5990
+ this.clipPageRequestPageIndices[count] = pageIndex;
5991
+ count += 1;
5992
+ }
5993
+ }
5994
+ const overflowCount = this.engine.clipPageRequestOverflowCount();
5995
+ const dropped = overflowCount - this.clipPageRequestOverflowReported >>> 0;
5996
+ this.clipPageRequestOverflowReported = overflowCount;
5997
+ if (ring) {
5998
+ if (dropped > 0) {
5999
+ Atomics.add(ring.header, 4, dropped);
6000
+ }
6001
+ for (let i = 0; i < count; ++i) {
6002
+ pushSonareClipPageRequestRingBuffer(
6003
+ ring,
6004
+ this.clipPageRequestClipIds[i],
6005
+ this.clipPageRequestPageIndices[i]
6006
+ );
6007
+ }
6008
+ return;
6009
+ }
6010
+ if (count === 0 && dropped === 0) {
6011
+ return;
6012
+ }
6013
+ if (!transport?.postMessage) {
6014
+ return;
6015
+ }
6016
+ const requests = new Array(count);
6017
+ for (let i = 0; i < count; ++i) {
6018
+ requests[i] = {
6019
+ clipId: this.clipPageRequestClipIds[i],
6020
+ pageIndex: this.clipPageRequestPageIndices[i]
6021
+ };
6022
+ }
6023
+ transport.postMessage({
6024
+ type: "clipPageRequest",
6025
+ requests,
6026
+ ...dropped > 0 ? { dropped } : {}
6027
+ });
6028
+ }
6029
+ removePagedClipProvider(clipId) {
6030
+ const providerId = this.pagedClipProviders.get(clipId);
6031
+ if (providerId !== void 0) {
6032
+ this.engine.destroyClipPageProvider(providerId);
6033
+ }
6034
+ this.pagedClipProviders.delete(clipId);
6035
+ this.pagedClipPageFrames.delete(clipId);
6036
+ this.pendingPagedClips.delete(clipId);
6037
+ }
6038
+ clearPagedClipProviders() {
6039
+ for (const clipId of this.pagedClipProviders.keys()) {
6040
+ this.removePagedClipProvider(clipId);
4505
6041
  }
4506
- Atomics.store(ring.header, 0, writeIndex + 1);
4507
6042
  }
4508
6043
  commandRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
4509
6044
  const ring = engineRingFromSharedBuffer(
@@ -4536,6 +6071,7 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
4536
6071
  constructor(options) {
4537
6072
  super();
4538
6073
  this.pendingMessages = [];
6074
+ this.pendingMessagesOverflowed = false;
4539
6075
  const port = this.port;
4540
6076
  const processorOptions = options?.processorOptions ?? {};
4541
6077
  void this.initializeEmbind(processorOptions, port);
@@ -4543,6 +6079,8 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
4543
6079
  if (!this.bridge) {
4544
6080
  if (this.pendingMessages.length < 1024) {
4545
6081
  this.pendingMessages.push(event.data);
6082
+ } else {
6083
+ this.pendingMessagesOverflowed = true;
4546
6084
  }
4547
6085
  return;
4548
6086
  }
@@ -4604,8 +6142,11 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
4604
6142
  moduleFactory
4605
6143
  });
4606
6144
  }
6145
+ if (this.pendingMessagesOverflowed) {
6146
+ throw new Error("AudioWorklet initialization message buffer overflowed.");
6147
+ }
4607
6148
  this.bridge = new SonareRealtimeEngineWorkletProcessor(options, {
4608
- postMessage: (message) => port?.postMessage?.(message),
6149
+ postMessage: (message, transfer) => port?.postMessage?.(message, transfer),
4609
6150
  onMeter: (meter) => port?.postMessage?.(meter)
4610
6151
  });
4611
6152
  for (const message of options.initialSyncMessages ?? []) {
@@ -4919,6 +6460,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4919
6460
  this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
4920
6461
  this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
4921
6462
  this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
6463
+ this.bufferGeneration = this.changer.bufferGeneration();
4922
6464
  this.planarChannels = [];
4923
6465
  if (this.channelCount > 1) {
4924
6466
  for (let ch = 0; ch < this.channelCount; ch++) {
@@ -4927,19 +6469,16 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4927
6469
  }
4928
6470
  }
4929
6471
  /**
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).
6472
+ * Handles a control-plane message from the main thread. AudioWorklet port
6473
+ * handlers run on the same audio rendering thread as `process()`: this path
6474
+ * therefore accepts only a pre-normalized POD and never parses JSON.
4936
6475
  */
4937
6476
  receiveMessage(message) {
4938
6477
  if (this.destroyed) {
4939
6478
  return;
4940
6479
  }
4941
6480
  if (message.type === "setConfig") {
4942
- this.changer.setConfig(message.preset);
6481
+ this.changer.setPodConfig(message.config);
4943
6482
  } else if (message.type === "reset") {
4944
6483
  this.changer.reset();
4945
6484
  } else if (message.type === "destroy") {
@@ -4951,7 +6490,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4951
6490
  if (this.destroyed || !output || output.length === 0) {
4952
6491
  return !this.destroyed;
4953
6492
  }
4954
- if (this.monoInput.byteLength === 0) {
6493
+ if (this.bufferGeneration !== this.changer.bufferGeneration() || this.monoInput.byteLength === 0) {
4955
6494
  this.reacquireBuffers();
4956
6495
  }
4957
6496
  const input = inputs[0];
@@ -4968,10 +6507,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
4968
6507
  } else {
4969
6508
  this.monoInput.fill(0, 0, frames2);
4970
6509
  }
4971
- this.changer.processMonoInto(
4972
- this.monoInput.subarray(0, frames2),
4973
- this.monoOutput.subarray(0, frames2)
4974
- );
6510
+ this.changer.processPreparedMono(frames2);
4975
6511
  output[0].set(this.monoOutput.subarray(0, frames2));
4976
6512
  return true;
4977
6513
  }
@@ -5017,6 +6553,7 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
5017
6553
  this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
5018
6554
  }
5019
6555
  }
6556
+ this.bufferGeneration = this.changer.bufferGeneration();
5020
6557
  }
5021
6558
  /**
5022
6559
  * Returns the number of frames we can actually process given the
@@ -5089,9 +6626,13 @@ function registerSonareRealtimeVoiceChangerWorkletProcessor(name = "sonare-realt
5089
6626
  scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
5090
6627
  }
5091
6628
  export {
6629
+ SONARE_CLIP_PAGE_REQUEST_RING_HEADER_INTS,
6630
+ SONARE_CLIP_PAGE_REQUEST_RING_RECORD_UINT32S,
5092
6631
  SONARE_ENGINE_COMMAND_RECORD_BYTES,
5093
6632
  SONARE_ENGINE_RING_HEADER_INTS,
5094
6633
  SONARE_ENGINE_TELEMETRY_RECORD_BYTES,
6634
+ SONARE_EXTERNAL_MIDI_RING_HEADER_INTS,
6635
+ SONARE_EXTERNAL_MIDI_RING_RECORD_UINT32S,
5095
6636
  SONARE_METER_RING_HEADER_INTS,
5096
6637
  SONARE_METER_RING_RECORD_FLOATS,
5097
6638
  SONARE_SCOPE_RING_HEADER_INTS,
@@ -5104,8 +6645,11 @@ export {
5104
6645
  SonareRealtimeEngineWorkletProcessor,
5105
6646
  SonareRealtimeVoiceChangerWorkletProcessor,
5106
6647
  SonareWorkletProcessor,
6648
+ attachOpfsClipStream,
6649
+ createSonareClipPageRequestRingBuffer,
5107
6650
  createSonareEngineCommandRingBuffer,
5108
6651
  createSonareEngineTelemetryRingBuffer,
6652
+ createSonareExternalMidiRingBuffer,
5109
6653
  createSonareMeterRingBuffer,
5110
6654
  createSonareScopeRingBuffer,
5111
6655
  createSonareSpectrumRingBuffer,
@@ -5115,16 +6659,22 @@ export {
5115
6659
  init,
5116
6660
  isInitialized,
5117
6661
  popSonareEngineCommandRingBuffer,
6662
+ pushSonareClipPageRequestRingBuffer,
5118
6663
  pushSonareEngineCommandRingBuffer,
6664
+ pushSonareExternalMidiRingBuffer,
6665
+ readSonareClipPageRequestRingBuffer,
5119
6666
  readSonareEngineTelemetryRingBuffer,
6667
+ readSonareExternalMidiRingBuffer,
5120
6668
  readSonareMeterRingBuffer,
5121
6669
  readSonareScopeRingBuffer,
5122
6670
  readSonareSpectrumRingBuffer,
5123
6671
  registerSonareRealtimeEngineWorkletProcessor,
5124
6672
  registerSonareRealtimeVoiceChangerWorkletProcessor,
5125
6673
  registerSonareWorkletProcessor,
6674
+ sonareClipPageRequestRingBufferByteLength,
5126
6675
  sonareEngineCommandRingBufferByteLength,
5127
6676
  sonareEngineTelemetryRingBufferByteLength,
6677
+ sonareExternalMidiRingBufferByteLength,
5128
6678
  sonareMeterRingBufferByteLength,
5129
6679
  sonareScopeRingBufferByteLength,
5130
6680
  sonareSpectrumRingBufferByteLength,