@libraz/libsonare 1.2.2 → 1.3.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 (44) hide show
  1. package/README.md +38 -1
  2. package/dist/index.d.ts +1 -2722
  3. package/dist/index.js +3659 -1896
  4. package/dist/index.js.map +1 -1
  5. package/dist/sonare-rt-module.js +1 -1
  6. package/dist/sonare-rt.js +1 -1
  7. package/dist/sonare-rt.wasm +0 -0
  8. package/dist/sonare.js +1 -1
  9. package/dist/sonare.wasm +0 -0
  10. package/dist/worklet.d.ts +4827 -455
  11. package/dist/worklet.js +1076 -494
  12. package/dist/worklet.js.map +1 -1
  13. package/package.json +2 -1
  14. package/src/analysis_helpers.ts +152 -0
  15. package/src/audio.ts +493 -0
  16. package/src/codes.ts +56 -0
  17. package/src/effects_mastering.ts +964 -0
  18. package/src/feature_core.ts +248 -0
  19. package/src/feature_music.ts +419 -0
  20. package/src/feature_pitch.ts +80 -0
  21. package/src/feature_resample.ts +21 -0
  22. package/src/feature_spectral.ts +330 -0
  23. package/src/feature_spectrogram.ts +454 -0
  24. package/src/features.ts +84 -0
  25. package/src/index.ts +352 -4793
  26. package/src/live_audio.ts +45 -0
  27. package/src/metering.ts +380 -0
  28. package/src/mixer.ts +523 -0
  29. package/src/module_state.ts +14 -0
  30. package/src/opfs_clip_pages.ts +188 -0
  31. package/src/project.ts +1614 -0
  32. package/src/public_types.ts +244 -2
  33. package/src/quick_analysis.ts +508 -0
  34. package/src/realtime_engine.ts +667 -0
  35. package/src/realtime_voice_changer.ts +275 -0
  36. package/src/scale.ts +42 -0
  37. package/src/sonare.js.d.ts +386 -4
  38. package/src/stream_analyzer.ts +275 -0
  39. package/src/stream_types.ts +29 -1
  40. package/src/streaming_mixing.ts +18 -0
  41. package/src/streaming_processors.ts +335 -0
  42. package/src/validation.ts +82 -0
  43. package/src/web_midi.ts +367 -0
  44. package/src/worklet.ts +525 -81
package/dist/worklet.js CHANGED
@@ -1,5 +1,16 @@
1
- // src/index.ts
2
- var EXPECTED_ENGINE_ABI_VERSION = 2;
1
+ // src/module_state.ts
2
+ var wasmModule = null;
3
+ function setSonareModule(module2) {
4
+ wasmModule = module2;
5
+ }
6
+ function getSonareModule() {
7
+ if (!wasmModule) {
8
+ throw new Error("Module not initialized. Call init() first.");
9
+ }
10
+ return wasmModule;
11
+ }
12
+
13
+ // src/codes.ts
3
14
  function automationCurveCode(curve) {
4
15
  switch (curve) {
5
16
  case "linear":
@@ -50,213 +61,396 @@ function meterTapCode(tap) {
50
61
  function sendTimingCode(timing) {
51
62
  return timing === "preFader" || timing === 0 ? 0 : 1;
52
63
  }
53
- var module = null;
54
- var initPromise = null;
55
- async function init(options) {
56
- if (module) {
57
- return;
64
+
65
+ // src/mixer.ts
66
+ var Mixer = class _Mixer {
67
+ constructor(mixer) {
68
+ this.mixer = mixer;
58
69
  }
59
- if (initPromise) {
60
- return initPromise;
70
+ /**
71
+ * Build a mixer from a scene JSON string.
72
+ *
73
+ * @param json - Scene JSON (strips, buses, sends, connections, inserts)
74
+ * @param sampleRate - Sample rate in Hz (default: 48000)
75
+ * @param blockSize - Maximum block size per {@link processStereo} call (default: 512)
76
+ */
77
+ static fromSceneJson(json, sampleRate = 48e3, blockSize = 512) {
78
+ const module2 = getSonareModule();
79
+ return new _Mixer(module2.createMixerFromSceneJson(json, sampleRate, blockSize));
61
80
  }
62
- initPromise = (async () => {
63
- try {
64
- const createModule = (await import("./sonare.js")).default;
65
- module = await createModule(options);
66
- } catch (error) {
67
- initPromise = null;
68
- throw error;
81
+ /** Rebuild and compile the routing graph from the current scene topology. */
82
+ compile() {
83
+ this.mixer.compile();
84
+ }
85
+ /**
86
+ * Mix one block of per-strip stereo audio into the stereo master.
87
+ *
88
+ * @param leftChannels - `leftChannels[i]` is the left channel of strip `i`
89
+ * @param rightChannels - `rightChannels[i]` is the right channel of strip `i`
90
+ * @returns Mixed stereo master (`left`, `right`, `sampleRate`)
91
+ */
92
+ processStereo(leftChannels, rightChannels) {
93
+ if (leftChannels.length !== rightChannels.length) {
94
+ throw new Error("leftChannels and rightChannels must have the same length.");
69
95
  }
70
- })();
71
- return initPromise;
72
- }
73
- function isInitialized() {
74
- return module !== null;
75
- }
76
- function engineAbiVersion() {
77
- if (!module) {
78
- throw new Error("Module not initialized. Call init() first.");
96
+ return this.mixer.processStereo(leftChannels, rightChannels);
79
97
  }
80
- return module.engineAbiVersion();
81
- }
82
- function engineCapabilities() {
83
- const abiVersion = engineAbiVersion();
84
- const sharedArrayBuffer = typeof globalThis.SharedArrayBuffer === "function";
85
- const atomics = typeof globalThis.Atomics === "object";
86
- const audioWorklet = typeof AudioWorkletNode !== "undefined" || typeof globalThis.AudioWorkletProcessor !== "undefined";
87
- return {
88
- engineAbiVersion: abiVersion,
89
- expectedEngineAbiVersion: EXPECTED_ENGINE_ABI_VERSION,
90
- abiCompatible: abiVersion === EXPECTED_ENGINE_ABI_VERSION,
91
- sharedArrayBuffer,
92
- atomics,
93
- audioWorklet,
94
- mode: sharedArrayBuffer && atomics ? "sab" : "postMessage"
95
- };
96
- }
97
- var RealtimeEngine = class {
98
- constructor(sampleRate = 48e3, maxBlockSize = 128, commandCapacity = 1024, telemetryCapacity = 1024) {
99
- if (!module) {
100
- throw new Error("Module not initialized. Call init() first.");
98
+ /**
99
+ * Mix one block into caller-owned output arrays.
100
+ *
101
+ * This avoids allocating the result object and result `Float32Array`s. It is
102
+ * intended for realtime bridges such as AudioWorklet; the input channel count
103
+ * must match the scene strip count and all arrays must have the same length.
104
+ */
105
+ processStereoInto(leftChannels, rightChannels, outLeft, outRight) {
106
+ if (leftChannels.length !== rightChannels.length) {
107
+ throw new Error("leftChannels and rightChannels must have the same length.");
101
108
  }
102
- const capabilities = engineCapabilities();
103
- if (!capabilities.abiCompatible) {
104
- throw new Error(
105
- `Engine ABI mismatch: wasm=${capabilities.engineAbiVersion}, expected=${capabilities.expectedEngineAbiVersion}`
106
- );
109
+ if (outLeft.length !== outRight.length) {
110
+ throw new Error("outLeft and outRight must have the same length.");
107
111
  }
108
- this.native = new module.RealtimeEngine(
109
- sampleRate,
110
- maxBlockSize,
111
- commandCapacity,
112
- telemetryCapacity
113
- );
114
- }
115
- prepare(sampleRate, maxBlockSize, commandCapacity = 1024, telemetryCapacity = 1024) {
116
- this.native.prepare(sampleRate, maxBlockSize, commandCapacity, telemetryCapacity);
117
- }
118
- /** Queue a sample-accurate parameter change (engine kSetParam). */
119
- setParameter(paramId, value, renderFrame = -1) {
120
- this.native.setParameter(paramId, value, renderFrame);
112
+ this.mixer.processStereoInto(leftChannels, rightChannels, outLeft, outRight);
121
113
  }
122
- /** Queue a smoothed parameter change (engine kSetParamSmoothed). */
123
- setParameterSmoothed(paramId, value, renderFrame = -1) {
124
- this.native.setParameterSmoothed(paramId, value, renderFrame);
114
+ /**
115
+ * Create reusable WASM-heap input/output views for realtime-style processing.
116
+ *
117
+ * Fill `leftInputs[i]` / `rightInputs[i]`, call `process()`, then read
118
+ * `outLeft` / `outRight`. The views are owned by this mixer and become invalid
119
+ * after {@link delete}.
120
+ */
121
+ createRealtimeBuffer() {
122
+ const stripCount = this.stripCount();
123
+ let leftInputs = [];
124
+ let rightInputs = [];
125
+ let outLeft = this.mixer.outputLeftView();
126
+ let outRight = this.mixer.outputRightView();
127
+ const acquire = () => {
128
+ leftInputs = [];
129
+ rightInputs = [];
130
+ for (let index = 0; index < stripCount; index++) {
131
+ leftInputs.push(this.mixer.inputLeftView(index));
132
+ rightInputs.push(this.mixer.inputRightView(index));
133
+ }
134
+ outLeft = this.mixer.outputLeftView();
135
+ outRight = this.mixer.outputRightView();
136
+ };
137
+ acquire();
138
+ const reacquireIfDetached = () => {
139
+ if (outLeft.byteLength === 0 || (leftInputs[0]?.byteLength ?? 1) === 0) {
140
+ acquire();
141
+ }
142
+ };
143
+ return {
144
+ get leftInputs() {
145
+ reacquireIfDetached();
146
+ return leftInputs;
147
+ },
148
+ get rightInputs() {
149
+ reacquireIfDetached();
150
+ return rightInputs;
151
+ },
152
+ get outLeft() {
153
+ reacquireIfDetached();
154
+ return outLeft;
155
+ },
156
+ get outRight() {
157
+ reacquireIfDetached();
158
+ return outRight;
159
+ },
160
+ process: (numSamples = outLeft.length) => {
161
+ reacquireIfDetached();
162
+ this.mixer.processPreparedStereo(numSamples);
163
+ }
164
+ };
125
165
  }
126
- /** Read back the current transport state snapshot. */
127
- getTransportState() {
128
- return this.native.getTransportState();
166
+ /** Number of strips in the mixer (e.g. strips loaded from the scene). */
167
+ stripCount() {
168
+ return this.mixer.stripCount();
129
169
  }
130
- play(renderFrame = -1) {
131
- this.native.play(renderFrame);
170
+ /**
171
+ * Schedule sample-accurate insert-parameter automation on a strip's insert.
172
+ *
173
+ * @param stripIndex - Strip index in `[0, stripCount())`
174
+ * @param insertIndex - Index into the strip's combined insert sequence
175
+ * (`[pre-inserts... post-inserts...]`)
176
+ * @param paramId - Processor-specific parameter id
177
+ * @param samplePos - Absolute samples from the start of processing (the mixer
178
+ * advances an internal position from 0 on the first {@link processStereo}
179
+ * call; recompiling resets it to 0)
180
+ * @param value - Target parameter value
181
+ * @param curve - Interpolation curve (default: `'linear'`)
182
+ * @throws If the strip index is out of range or the schedule call fails
183
+ * (unknown curve, out-of-range insert index, or full event lane)
184
+ */
185
+ scheduleInsertAutomation(stripIndex, insertIndex, paramId, samplePos, value, curve = "linear") {
186
+ this.mixer.scheduleInsertAutomation(
187
+ stripIndex,
188
+ insertIndex,
189
+ paramId,
190
+ samplePos,
191
+ value,
192
+ automationCurveCode(curve)
193
+ );
132
194
  }
133
- stop(renderFrame = -1) {
134
- this.native.stop(renderFrame);
195
+ /**
196
+ * Resolve a strip's index in `[0, stripCount())` from its scene id, or `null`
197
+ * when no strip with that id exists (matches the Node binding's `number | null`).
198
+ */
199
+ stripById(id) {
200
+ const index = this.mixer.stripById(id);
201
+ return index < 0 ? null : index;
135
202
  }
136
- seekSample(timelineSample, renderFrame = -1) {
137
- this.native.seekSample(timelineSample, renderFrame);
203
+ /**
204
+ * Add a bus to the mixer topology. `role` is one of `'master'`, `'aux'`, or
205
+ * `'submix'` (defaults to `'aux'`). Marks the routing graph dirty; call
206
+ * {@link compile} (or {@link processStereo}) to rebuild.
207
+ */
208
+ addBus(id, role = "aux") {
209
+ this.mixer.addBus(id, role);
138
210
  }
139
- seekPpq(ppq, renderFrame = -1) {
140
- this.native.seekPpq(ppq, renderFrame);
211
+ /** Remove a bus by id. Marks the routing graph dirty. */
212
+ removeBus(id) {
213
+ this.mixer.removeBus(id);
141
214
  }
142
- setTempo(bpm) {
143
- this.native.setTempo(bpm);
215
+ /** Number of buses in the mixer topology. */
216
+ busCount() {
217
+ return this.mixer.busCount();
144
218
  }
145
- setTimeSignature(numerator, denominator) {
146
- this.native.setTimeSignature(numerator, denominator);
219
+ /**
220
+ * Add a VCA group with the given gain offset (dB). `members` is a list of
221
+ * strip ids governed by the group (may be empty).
222
+ */
223
+ addVcaGroup(id, gainDb = 0, members = []) {
224
+ this.mixer.addVcaGroup(id, gainDb, members);
147
225
  }
148
- setLoop(startPpq, endPpq, enabled = true) {
149
- this.native.setLoop(startPpq, endPpq, enabled);
226
+ /** Set an existing VCA group's gain in dB. */
227
+ setVcaGroupGainDb(id, gainDb) {
228
+ this.mixer.setVcaGroupGainDb(id, gainDb);
150
229
  }
151
- addParameter(info) {
152
- this.native.addParameter(info);
230
+ /** Remove a VCA group by id. */
231
+ removeVcaGroup(id) {
232
+ this.mixer.removeVcaGroup(id);
153
233
  }
154
- parameterCount() {
155
- return this.native.parameterCount();
234
+ /** Number of VCA groups in the mixer topology. */
235
+ vcaGroupCount() {
236
+ return this.mixer.vcaGroupCount();
156
237
  }
157
- parameterInfoByIndex(index) {
158
- return this.native.parameterInfoByIndex(index);
238
+ /** Set the strip's input trim in dB. */
239
+ setInputTrimDb(stripIndex, db) {
240
+ this.mixer.setInputTrimDb(stripIndex, db);
159
241
  }
160
- parameterInfo(id) {
161
- return this.native.parameterInfo(id);
242
+ /** Set the strip's fader level in dB. */
243
+ setFaderDb(stripIndex, db) {
244
+ this.mixer.setFaderDb(stripIndex, db);
162
245
  }
163
- setAutomationLane(paramId, points) {
164
- this.native.setAutomationLane(paramId, points);
246
+ /**
247
+ * Set the strip's pan position.
248
+ *
249
+ * @param stripIndex - Strip index in `[0, stripCount())`
250
+ * @param pan - Pan position in `[-1, 1]`
251
+ * @param panMode - Optional pan mode. When omitted the strip's current pan
252
+ * mode is kept (passes `SONARE_PAN_MODE_KEEP`), so a plain pan nudge does
253
+ * not reset a scene-defined `'stereoPan'` / `'dualPan'` mode back to
254
+ * balance. Pass `'balance'` (or `0`) explicitly to force balance mode.
255
+ */
256
+ setPan(stripIndex, pan, panMode) {
257
+ const mode = panMode === void 0 ? -1 : panModeCode(panMode);
258
+ this.mixer.setPan(stripIndex, pan, mode);
165
259
  }
166
- automationLaneCount() {
167
- return this.native.automationLaneCount();
260
+ /** Set the strip's stereo width. */
261
+ setWidth(stripIndex, width) {
262
+ this.mixer.setWidth(stripIndex, width);
168
263
  }
169
- setMarkers(markers) {
170
- this.native.setMarkers(markers);
264
+ /** Set the strip's mute state. */
265
+ setMuted(stripIndex, muted) {
266
+ this.mixer.setMuted(stripIndex, muted);
171
267
  }
172
- markerCount() {
173
- return this.native.markerCount();
268
+ /**
269
+ * Set a strip's solo state. Takes effect on the next process without a
270
+ * graph recompile.
271
+ */
272
+ setSoloed(stripIndex, soloed) {
273
+ this.mixer.setSoloed(stripIndex, soloed);
174
274
  }
175
- markerByIndex(index) {
176
- return this.native.markerByIndex(index);
275
+ /**
276
+ * Mark a strip solo-safe so it is never implied-muted by another strip's
277
+ * solo. Takes effect on the next process without a graph recompile.
278
+ */
279
+ setSoloSafe(stripIndex, soloSafe) {
280
+ this.mixer.setSoloSafe(stripIndex, soloSafe);
177
281
  }
178
- marker(id) {
179
- return this.native.marker(id);
282
+ /** Invert the polarity of the left and/or right channel of a strip. */
283
+ setPolarityInvert(stripIndex, invertLeft, invertRight) {
284
+ this.mixer.setPolarityInvert(stripIndex, invertLeft, invertRight);
180
285
  }
181
- seekMarker(markerId, renderFrame = -1) {
182
- this.native.seekMarker(markerId, renderFrame);
286
+ /** Set the strip's pan law. */
287
+ setPanLaw(stripIndex, panLaw) {
288
+ this.mixer.setPanLaw(stripIndex, panLawCode(panLaw));
183
289
  }
184
- setLoopFromMarkers(startMarkerId, endMarkerId) {
185
- this.native.setLoopFromMarkers(startMarkerId, endMarkerId);
186
- }
187
- setMetronome(config) {
188
- this.native.setMetronome(config);
189
- }
190
- metronome() {
191
- return this.native.metronome();
192
- }
193
- countInEndSample(startSample, bars) {
194
- return Number(this.native.countInEndSample(startSample, bars));
195
- }
196
- setGraph(spec) {
197
- this.native.setGraph(spec);
198
- }
199
- graphNodeCount() {
200
- return this.native.graphNodeCount();
290
+ /**
291
+ * Set a per-strip channel delay in samples. This changes the strip's reported
292
+ * latency; recompile to re-run latency compensation.
293
+ */
294
+ setChannelDelaySamples(stripIndex, delaySamples) {
295
+ this.mixer.setChannelDelaySamples(stripIndex, delaySamples);
201
296
  }
202
- graphConnectionCount() {
203
- return this.native.graphConnectionCount();
297
+ /** Set the strip's live VCA gain offset in dB (not persisted to the scene). */
298
+ setVcaOffsetDb(stripIndex, offsetDb) {
299
+ this.mixer.setVcaOffsetDb(stripIndex, offsetDb);
204
300
  }
205
- setClips(clips) {
206
- this.native.setClips(clips);
301
+ /** Set independent left/right pan positions (dual-pan mode). */
302
+ setDualPan(stripIndex, leftPan, rightPan) {
303
+ this.mixer.setDualPan(stripIndex, leftPan, rightPan);
207
304
  }
208
- clipCount() {
209
- return this.native.clipCount();
305
+ /**
306
+ * Add a send to a strip after construction.
307
+ *
308
+ * @param stripIndex - Strip index in `[0, stripCount())`
309
+ * @param id - Send id
310
+ * @param destinationBusId - Destination bus id
311
+ * @param sendDb - Initial send level in dB
312
+ * @param timing - `'preFader'` or `'postFader'` (default: `'postFader'`)
313
+ * @returns The new send's index
314
+ */
315
+ addSend(stripIndex, id, destinationBusId, sendDb = 0, timing = "postFader") {
316
+ return this.mixer.addSend(stripIndex, id, destinationBusId, sendDb, sendTimingCode(timing));
210
317
  }
211
- setCaptureBuffer(numChannels, capacityFrames) {
212
- this.native.setCaptureBuffer(numChannels, capacityFrames);
318
+ /** Set the send level (in dB) for an existing send by index. */
319
+ setSendDb(stripIndex, sendIndex, sendDb) {
320
+ this.mixer.setSendDb(stripIndex, sendIndex, sendDb);
213
321
  }
214
- armCapture(armed = true) {
215
- this.native.armCapture(armed);
322
+ /**
323
+ * Remove an existing send from a strip by index.
324
+ *
325
+ * Sends are addressed in add order. After removal, sends with a higher index
326
+ * than `sendIndex` shift down by one. Recompile (or process) before reading
327
+ * results so the routing graph rebuilds.
328
+ *
329
+ * @param stripIndex - Strip index in `[0, stripCount())`
330
+ * @param sendIndex - Send index in add order
331
+ */
332
+ removeSend(stripIndex, sendIndex) {
333
+ this.mixer.removeSend(stripIndex, sendIndex);
216
334
  }
217
- setCapturePunch(startSample, endSample, enabled = true) {
218
- this.native.setCapturePunch(startSample, endSample, enabled);
335
+ /**
336
+ * Read a strip's meter snapshot at the given tap point.
337
+ *
338
+ * @param stripIndex - Strip index in `[0, stripCount())`
339
+ * @param tap - `'preFader'` or `'postFader'` (default: `'postFader'`)
340
+ */
341
+ meterTap(stripIndex, tap = "postFader") {
342
+ return this.mixer.meterTap(stripIndex, meterTapCode(tap));
219
343
  }
220
- resetCapture() {
221
- this.native.resetCapture();
344
+ /**
345
+ * Read a strip's meter snapshot.
346
+ *
347
+ * With no `tap` argument this reads the strip's own (post-fader) meter,
348
+ * matching the Node/Python tap-less `stripMeter` contract. Pass an optional
349
+ * `tap` (`'preFader'` / `'postFader'`) to read the tap-selectable snapshot
350
+ * instead — the same backing call as {@link meterTap}.
351
+ *
352
+ * @param stripIndex - Strip index in `[0, stripCount())`
353
+ * @param tap - Optional tap point (`'preFader'` / `'postFader'`); when omitted
354
+ * the tap-less post-fader strip meter is read.
355
+ */
356
+ stripMeter(stripIndex, tap) {
357
+ if (tap === void 0) {
358
+ return this.mixer.stripMeter(stripIndex);
359
+ }
360
+ return this.mixer.meterTap(stripIndex, meterTapCode(tap));
222
361
  }
223
- captureStatus() {
224
- return this.native.captureStatus();
362
+ /**
363
+ * Schedule sample-accurate fader automation on a strip.
364
+ *
365
+ * @param stripIndex - Strip index in `[0, stripCount())`
366
+ * @param samplePos - Absolute samples from the start of processing
367
+ * @param faderDb - Target fader level in dB
368
+ * @param curve - Interpolation curve (default: `'linear'`)
369
+ */
370
+ scheduleFaderAutomation(stripIndex, samplePos, faderDb, curve = "linear") {
371
+ this.mixer.scheduleFaderAutomation(stripIndex, samplePos, faderDb, automationCurveCode(curve));
225
372
  }
226
- capturedAudio() {
227
- return this.native.capturedAudio();
373
+ /**
374
+ * Schedule sample-accurate pan automation on a strip.
375
+ *
376
+ * @param stripIndex - Strip index in `[0, stripCount())`
377
+ * @param samplePos - Absolute samples from the start of processing
378
+ * @param pan - Target pan position
379
+ * @param curve - Interpolation curve (default: `'linear'`)
380
+ */
381
+ schedulePanAutomation(stripIndex, samplePos, pan, curve = "linear") {
382
+ this.mixer.schedulePanAutomation(stripIndex, samplePos, pan, automationCurveCode(curve));
228
383
  }
229
- process(channels) {
230
- return this.native.process(channels);
384
+ /**
385
+ * Schedule sample-accurate width automation on a strip.
386
+ *
387
+ * @param stripIndex - Strip index in `[0, stripCount())`
388
+ * @param samplePos - Absolute samples from the start of processing
389
+ * @param width - Target stereo width
390
+ * @param curve - Interpolation curve (default: `'linear'`)
391
+ */
392
+ scheduleWidthAutomation(stripIndex, samplePos, width, curve = "linear") {
393
+ this.mixer.scheduleWidthAutomation(stripIndex, samplePos, width, automationCurveCode(curve));
231
394
  }
232
- processWithMonitor(channels) {
233
- return this.native.processWithMonitor(channels);
395
+ /**
396
+ * Schedule sample-accurate send-level automation on a strip's send.
397
+ *
398
+ * @param stripIndex - Strip index in `[0, stripCount())`
399
+ * @param sendIndex - Send index in the strip's add order
400
+ * @param samplePos - Absolute samples from the start of processing
401
+ * @param db - Target send level in dB
402
+ * @param curve - Interpolation curve (default: `'linear'`)
403
+ */
404
+ scheduleSendAutomation(stripIndex, sendIndex, samplePos, db, curve = "linear") {
405
+ this.mixer.scheduleSendAutomation(
406
+ stripIndex,
407
+ sendIndex,
408
+ samplePos,
409
+ db,
410
+ automationCurveCode(curve)
411
+ );
234
412
  }
235
- renderOffline(channels, blockSize = 128) {
236
- return this.native.renderOffline(channels, blockSize);
413
+ /**
414
+ * Read up to `maxPoints` of a strip's most recent goniometer samples
415
+ * (oldest to newest).
416
+ */
417
+ readGoniometerLatest(stripIndex, maxPoints) {
418
+ return this.mixer.readGoniometerLatest(stripIndex, maxPoints);
237
419
  }
238
- bounceOffline(options) {
239
- return this.native.bounceOffline(options);
420
+ /** Serialize the current scene (strips, buses, sends, connections) to JSON. */
421
+ toSceneJson() {
422
+ return this.mixer.toSceneJson();
240
423
  }
241
- freezeOffline(options) {
242
- return this.native.freezeOffline(options);
424
+ /**
425
+ * Maximum processor tail length (samples) in the compiled mixer graph. Lazily
426
+ * compiles the routing graph if the topology is dirty.
427
+ */
428
+ tailSamples() {
429
+ return this.mixer.tailSamples();
243
430
  }
244
- drainTelemetry(maxRecords = 1024) {
245
- return this.native.drainTelemetry(maxRecords);
431
+ /**
432
+ * Drain delayed / tail audio by processing a zero-input block of `numSamples`
433
+ * frames after the host stops feeding strip inputs. Returns the mixed stereo
434
+ * master (`left`, `right`, `sampleRate`).
435
+ */
436
+ drainTailStereo(numSamples) {
437
+ return this.mixer.drainTailStereo(numSamples);
246
438
  }
247
- drainMeterTelemetry(maxRecords = 1024) {
248
- return this.native.drainMeterTelemetry(maxRecords);
439
+ /** Release the underlying WASM object. Safe to call only once. */
440
+ delete() {
441
+ this.mixer.delete();
249
442
  }
443
+ /** Alias for {@link delete}, provided for cross-binding (Node) compatibility. */
250
444
  destroy() {
251
- this.native.delete();
445
+ this.delete();
252
446
  }
253
447
  };
448
+
449
+ // src/realtime_voice_changer.ts
254
450
  var RealtimeVoiceChanger = class {
255
451
  constructor(config = "neutral-monitor") {
256
- if (!module) {
257
- throw new Error("Module not initialized. Call init() first.");
258
- }
259
- this.changer = module.createRealtimeVoiceChanger(config);
452
+ const module2 = getSonareModule();
453
+ this.changer = module2.createRealtimeVoiceChanger(config);
260
454
  }
261
455
  prepare(sampleRate, maxBlockSize = 128, channels = 1) {
262
456
  this.changer.prepare(sampleRate, maxBlockSize, channels);
@@ -347,23 +541,53 @@ var RealtimeVoiceChanger = class {
347
541
  * views are reused across calls and become invalid after {@link delete}.
348
542
  */
349
543
  createRealtimeMonoBuffer(numSamples) {
350
- const input = this.getMonoInputBuffer(numSamples);
351
- const output = this.getMonoOutputBuffer(numSamples);
544
+ let input = this.getMonoInputBuffer(numSamples);
545
+ let output = this.getMonoOutputBuffer(numSamples);
546
+ const reacquireIfDetached = () => {
547
+ if (input.byteLength === 0 || output.byteLength === 0) {
548
+ input = this.getMonoInputBuffer(numSamples);
549
+ output = this.getMonoOutputBuffer(numSamples);
550
+ }
551
+ };
352
552
  return {
353
- input,
354
- output,
355
- process: () => this.processPreparedMono(numSamples)
553
+ get input() {
554
+ reacquireIfDetached();
555
+ return input;
556
+ },
557
+ get output() {
558
+ reacquireIfDetached();
559
+ return output;
560
+ },
561
+ process: () => {
562
+ reacquireIfDetached();
563
+ this.processPreparedMono(numSamples);
564
+ }
356
565
  };
357
566
  }
358
567
  /** Same as {@link createRealtimeMonoBuffer} but for interleaved I/O. */
359
568
  createRealtimeInterleavedBuffer(numFrames, numChannels) {
360
- const input = this.getInterleavedInputBuffer(numFrames, numChannels);
361
- const output = this.getInterleavedOutputBuffer(numFrames, numChannels);
569
+ let input = this.getInterleavedInputBuffer(numFrames, numChannels);
570
+ let output = this.getInterleavedOutputBuffer(numFrames, numChannels);
571
+ const reacquireIfDetached = () => {
572
+ if (input.byteLength === 0 || output.byteLength === 0) {
573
+ input = this.getInterleavedInputBuffer(numFrames, numChannels);
574
+ output = this.getInterleavedOutputBuffer(numFrames, numChannels);
575
+ }
576
+ };
362
577
  return {
363
- input,
364
- output,
578
+ get input() {
579
+ reacquireIfDetached();
580
+ return input;
581
+ },
582
+ get output() {
583
+ reacquireIfDetached();
584
+ return output;
585
+ },
365
586
  channels: numChannels,
366
- process: () => this.processPreparedInterleaved(numFrames, numChannels)
587
+ process: () => {
588
+ reacquireIfDetached();
589
+ this.processPreparedInterleaved(numFrames, numChannels);
590
+ }
367
591
  };
368
592
  }
369
593
  /**
@@ -373,331 +597,451 @@ var RealtimeVoiceChanger = class {
373
597
  * become invalid after {@link delete}.
374
598
  */
375
599
  createRealtimePlanarBuffer(numFrames, numChannels) {
376
- const channels = [];
377
- for (let ch = 0; ch < numChannels; ch++) {
378
- channels.push(this.getPlanarChannelBuffer(ch, numFrames));
379
- }
600
+ let channels = [];
601
+ const acquire = () => {
602
+ channels = [];
603
+ for (let ch = 0; ch < numChannels; ch++) {
604
+ channels.push(this.getPlanarChannelBuffer(ch, numFrames));
605
+ }
606
+ };
607
+ acquire();
608
+ const reacquireIfDetached = () => {
609
+ if ((channels[0]?.byteLength ?? 0) === 0) {
610
+ acquire();
611
+ }
612
+ };
380
613
  return {
381
- channels,
382
- process: () => this.processPreparedPlanar(numFrames)
614
+ get channels() {
615
+ reacquireIfDetached();
616
+ return channels;
617
+ },
618
+ process: () => {
619
+ reacquireIfDetached();
620
+ this.processPreparedPlanar(numFrames);
621
+ }
383
622
  };
384
623
  }
385
624
  delete() {
386
625
  this.changer.delete();
387
626
  }
388
627
  };
389
- var Mixer = class _Mixer {
390
- constructor(mixer) {
391
- this.mixer = mixer;
628
+
629
+ // src/realtime_engine.ts
630
+ var EXPECTED_ENGINE_ABI_VERSION = 3;
631
+ function engineCapabilities() {
632
+ const abiVersion = getSonareModule().engineAbiVersion();
633
+ const sharedArrayBuffer = typeof globalThis.SharedArrayBuffer === "function";
634
+ const atomics = typeof globalThis.Atomics === "object";
635
+ const audioWorklet = typeof AudioWorkletNode !== "undefined" || typeof globalThis.AudioWorkletProcessor !== "undefined";
636
+ return {
637
+ engineAbiVersion: abiVersion,
638
+ expectedEngineAbiVersion: EXPECTED_ENGINE_ABI_VERSION,
639
+ abiCompatible: abiVersion === EXPECTED_ENGINE_ABI_VERSION,
640
+ sharedArrayBuffer,
641
+ atomics,
642
+ audioWorklet,
643
+ mode: sharedArrayBuffer && atomics ? "sab" : "postMessage"
644
+ };
645
+ }
646
+ var RealtimeEngine = class {
647
+ nativeExt() {
648
+ return this.native;
392
649
  }
393
- /**
394
- * Build a mixer from a scene JSON string.
395
- *
396
- * @param json - Scene JSON (strips, buses, sends, connections, inserts)
397
- * @param sampleRate - Sample rate in Hz (default: 48000)
398
- * @param blockSize - Maximum block size per {@link processStereo} call (default: 512)
399
- */
400
- static fromSceneJson(json, sampleRate = 48e3, blockSize = 512) {
401
- if (!module) {
402
- throw new Error("Module not initialized. Call init() first.");
650
+ constructor(sampleRate = 48e3, maxBlockSize = 128, commandCapacity = 1024, telemetryCapacity = 1024) {
651
+ const module2 = getSonareModule();
652
+ const capabilities = engineCapabilities();
653
+ if (!capabilities.abiCompatible) {
654
+ throw new Error(
655
+ `Engine ABI mismatch: wasm=${capabilities.engineAbiVersion}, expected=${capabilities.expectedEngineAbiVersion}`
656
+ );
403
657
  }
404
- return new _Mixer(module.createMixerFromSceneJson(json, sampleRate, blockSize));
658
+ this.native = new module2.RealtimeEngine(
659
+ sampleRate,
660
+ maxBlockSize,
661
+ commandCapacity,
662
+ telemetryCapacity
663
+ );
405
664
  }
406
- /** Rebuild and compile the routing graph from the current scene topology. */
407
- compile() {
408
- this.mixer.compile();
665
+ prepare(sampleRate, maxBlockSize, commandCapacity = 1024, telemetryCapacity = 1024) {
666
+ this.native.prepare(sampleRate, maxBlockSize, commandCapacity, telemetryCapacity);
667
+ }
668
+ /** Queue a sample-accurate parameter change (engine kSetParam). */
669
+ setParameter(paramId, value, renderFrame = -1) {
670
+ this.native.setParameter(paramId, value, renderFrame);
671
+ }
672
+ /** Queue a smoothed parameter change (engine kSetParamSmoothed). */
673
+ setParameterSmoothed(paramId, value, renderFrame = -1) {
674
+ this.native.setParameterSmoothed(paramId, value, renderFrame);
675
+ }
676
+ setBuiltinInstrument(config = {}, destinationId = config.destinationId ?? 0) {
677
+ this.nativeExt().setBuiltinInstrument(destinationId, config);
409
678
  }
410
679
  /**
411
- * Mix one block of per-strip stereo audio into the stereo master.
412
- *
413
- * @param leftChannels - `leftChannels[i]` is the left channel of strip `i`
414
- * @param rightChannels - `rightChannels[i]` is the right channel of strip `i`
415
- * @returns Mixed stereo master (`left`, `right`, `sampleRate`)
680
+ * Bind the patch-driven NativeSynth to a realtime MIDI destination. `patch`
681
+ * is a {@link SynthPatch} or a preset-name string (`'saw-lead'` /
682
+ * `'va:saw-lead'`; see {@link synthPresetNames}), resolving exactly like
683
+ * {@link Project.bounceWithSynthInstrument}. Live note/CC commands and
684
+ * scheduled MIDI clips routed to that destination render through the synth.
685
+ * Unknown preset names throw. An object patch's `destinationId` is a JS
686
+ * binding convenience, not part of the NativeSynth patch itself.
416
687
  */
417
- processStereo(leftChannels, rightChannels) {
418
- if (leftChannels.length !== rightChannels.length) {
419
- throw new Error("leftChannels and rightChannels must have the same length.");
420
- }
421
- return this.mixer.processStereo(leftChannels, rightChannels);
688
+ setSynthInstrument(patch = {}, destinationId = (typeof patch === "object" ? patch.destinationId : void 0) ?? 0) {
689
+ this.nativeExt().setSynthInstrument(destinationId, patch);
422
690
  }
423
691
  /**
424
- * Mix one block into caller-owned output arrays.
425
- *
426
- * This avoids allocating the result object and result `Float32Array`s. It is
427
- * intended for realtime bridges such as AudioWorklet; the input channel count
428
- * must match the scene strip count and all arrays must have the same length.
692
+ * Load (parse) SoundFont 2 bytes into the engine so SF2 instruments can be
693
+ * bound with {@link setSf2Instrument}. The host fetches the `.sf2` and
694
+ * passes the raw bytes; they are copied into linear memory for the call and
695
+ * not referenced afterwards. Replaces any previously loaded SoundFont.
429
696
  */
430
- processStereoInto(leftChannels, rightChannels, outLeft, outRight) {
431
- if (leftChannels.length !== rightChannels.length) {
432
- throw new Error("leftChannels and rightChannels must have the same length.");
433
- }
434
- if (outLeft.length !== outRight.length) {
435
- throw new Error("outLeft and outRight must have the same length.");
436
- }
437
- this.mixer.processStereoInto(leftChannels, rightChannels, outLeft, outRight);
697
+ loadSoundFont(data) {
698
+ this.nativeExt().loadSoundFont(data);
438
699
  }
439
700
  /**
440
- * Create reusable WASM-heap input/output views for realtime-style processing.
441
- *
442
- * Fill `leftInputs[i]` / `rightInputs[i]`, call `process()`, then read
443
- * `outLeft` / `outRight`. The views are owned by this mixer and become invalid
444
- * after {@link delete}.
701
+ * Bind a GS-compatible SoundFont player to a realtime MIDI destination, fed
702
+ * by the engine's loaded SoundFont ({@link loadSoundFont}). Live note/CC
703
+ * commands and scheduled MIDI clips routed to that destination render
704
+ * through the player (16 MIDI channels, channel 10 drums, GS NRPN part
705
+ * edits, GS/GM SysEx resets). Without a loaded SoundFont — or for programs
706
+ * the SoundFont does not cover — notes play through the built-in
707
+ * synthesizer GM fallback bank (the data-free floor).
445
708
  */
446
- createRealtimeBuffer() {
447
- const stripCount = this.stripCount();
448
- const leftInputs = [];
449
- const rightInputs = [];
450
- for (let index = 0; index < stripCount; index++) {
451
- leftInputs.push(this.mixer.inputLeftView(index));
452
- rightInputs.push(this.mixer.inputRightView(index));
453
- }
454
- const outLeft = this.mixer.outputLeftView();
455
- const outRight = this.mixer.outputRightView();
456
- return {
457
- leftInputs,
458
- rightInputs,
459
- outLeft,
460
- outRight,
461
- process: (numSamples = outLeft.length) => this.mixer.processPreparedStereo(numSamples)
462
- };
709
+ setSf2Instrument(config = {}, destinationId = config.destinationId ?? 0) {
710
+ this.nativeExt().setSf2Instrument(destinationId, config);
463
711
  }
464
- /** Number of strips in the mixer (e.g. strips loaded from the scene). */
465
- stripCount() {
466
- return this.mixer.stripCount();
712
+ clearMidiInstrument(destinationId = 0) {
713
+ this.nativeExt().clearMidiInstrument(destinationId);
714
+ }
715
+ midiInstrumentCount() {
716
+ return this.nativeExt().midiInstrumentCount();
467
717
  }
468
718
  /**
469
- * Schedule sample-accurate insert-parameter automation on a strip's insert.
470
- *
471
- * @param stripIndex - Strip index in `[0, stripCount())`
472
- * @param insertIndex - Index into the strip's combined insert sequence
473
- * (`[pre-inserts... post-inserts...]`)
474
- * @param paramId - Processor-specific parameter id
475
- * @param samplePos - Absolute samples from the start of processing (the mixer
476
- * advances an internal position from 0 on the first {@link processStereo}
477
- * call; recompiling resets it to 0)
478
- * @param value - Target parameter value
479
- * @param curve - Interpolation curve (default: `'linear'`)
480
- * @throws If the strip index is out of range or the schedule call fails
481
- * (unknown curve, out-of-range insert index, or full event lane)
719
+ * Bind a live MIDI CC to an engine automation parameter. The MIDI event still
720
+ * reaches the destination instrument; when bound, its 7-bit value is also
721
+ * mapped into [minValue, maxValue] for `paramId`.
482
722
  */
483
- scheduleInsertAutomation(stripIndex, insertIndex, paramId, samplePos, value, curve = "linear") {
484
- this.mixer.scheduleInsertAutomation(
485
- stripIndex,
486
- insertIndex,
723
+ bindMidiCc(channel, controller, paramId, options = {}) {
724
+ this.nativeExt().bindMidiCc(
725
+ channel,
726
+ controller,
487
727
  paramId,
488
- samplePos,
489
- value,
490
- automationCurveCode(curve)
728
+ options.minValue ?? 0,
729
+ options.maxValue ?? 1
491
730
  );
492
731
  }
493
- /**
494
- * Resolve a strip's index in `[0, stripCount())` from its scene id, or `null`
495
- * when no strip with that id exists (matches the Node binding's `number | null`).
496
- */
497
- stripById(id) {
498
- const index = this.mixer.stripById(id);
499
- return index < 0 ? null : index;
732
+ clearMidiCcBindings() {
733
+ this.nativeExt().clearMidiCcBindings();
500
734
  }
501
- /**
502
- * Add a bus to the mixer topology. `role` is one of `'master'`, `'aux'`, or
503
- * `'submix'` (defaults to `'aux'`). Marks the routing graph dirty; call
504
- * {@link compile} (or {@link processStereo}) to rebuild.
505
- */
506
- addBus(id, role = "aux") {
507
- this.mixer.addBus(id, role);
735
+ midiCcBindingCount() {
736
+ return this.nativeExt().midiCcBindingCount();
508
737
  }
509
- /** Remove a bus by id. Marks the routing graph dirty. */
510
- removeBus(id) {
511
- this.mixer.removeBus(id);
738
+ /** Install/replace a live non-destructive MIDI-FX insert for one destination. */
739
+ setMidiFx(destinationId, configJson) {
740
+ this.nativeExt().setMidiFx(destinationId, configJson);
512
741
  }
513
- /** Number of buses in the mixer topology. */
514
- busCount() {
515
- return this.mixer.busCount();
742
+ clearMidiFx(destinationId = 0) {
743
+ this.nativeExt().clearMidiFx(destinationId);
516
744
  }
517
- /**
518
- * Add a VCA group with the given gain offset (dB). `members` is a list of
519
- * strip ids governed by the group (may be empty).
520
- */
521
- addVcaGroup(id, gainDb = 0, members = []) {
522
- this.mixer.addVcaGroup(id, gainDb, members);
745
+ /** Enable the engine-owned live MIDI input source for a destination. */
746
+ setMidiInputSource(destinationId = 0) {
747
+ this.nativeExt().setMidiInputSource(destinationId);
523
748
  }
524
- /** Remove a VCA group by id. */
525
- removeVcaGroup(id) {
526
- this.mixer.removeVcaGroup(id);
749
+ clearMidiInputSource() {
750
+ this.nativeExt().clearMidiInputSource();
527
751
  }
528
- /** Number of VCA groups in the mixer topology. */
529
- vcaGroupCount() {
530
- return this.mixer.vcaGroupCount();
752
+ midiInputPendingCount() {
753
+ return this.nativeExt().midiInputPendingCount();
531
754
  }
532
- /** Set the strip's input trim in dB. */
533
- setInputTrimDb(stripIndex, db) {
534
- this.mixer.setInputTrimDb(stripIndex, db);
755
+ pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples = 0) {
756
+ this.nativeExt().pushMidiInputNoteOn(group, channel, note, velocity, portTimeSamples);
535
757
  }
536
- /** Set the strip's fader level in dB. */
537
- setFaderDb(stripIndex, db) {
538
- this.mixer.setFaderDb(stripIndex, db);
758
+ pushMidiInputNoteOff(group, channel, note, velocity = 0, portTimeSamples = 0) {
759
+ this.nativeExt().pushMidiInputNoteOff(group, channel, note, velocity, portTimeSamples);
539
760
  }
540
- /** Set the strip's pan position. */
541
- setPan(stripIndex, pan, panMode = 0) {
542
- this.mixer.setPan(stripIndex, pan, panModeCode(panMode));
761
+ pushMidiInputCc(group, channel, controller, value, portTimeSamples = 0) {
762
+ this.nativeExt().pushMidiInputCc(group, channel, controller, value, portTimeSamples);
543
763
  }
544
- /** Set the strip's stereo width. */
545
- setWidth(stripIndex, width) {
546
- this.mixer.setWidth(stripIndex, width);
764
+ pushMidiNoteOn(destinationId, group, channel, note, velocity, renderFrame = -1) {
765
+ this.nativeExt().pushMidiNoteOn(destinationId, group, channel, note, velocity, renderFrame);
547
766
  }
548
- /** Set the strip's mute state. */
549
- setMuted(stripIndex, muted) {
550
- this.mixer.setMuted(stripIndex, muted);
767
+ pushMidiNoteOff(destinationId, group, channel, note, velocity = 0, renderFrame = -1) {
768
+ this.nativeExt().pushMidiNoteOff(destinationId, group, channel, note, velocity, renderFrame);
551
769
  }
552
770
  /**
553
- * Set a strip's solo state. Takes effect on the next process without a
554
- * graph recompile.
771
+ * Queue an immediate (live) MIDI control change to a MIDI destination
772
+ * (engine kMidiCcImmediate). `group`/`channel` are 0..15; `controller`/`value`
773
+ * are 7-bit (0..127). `renderFrame` is the frame to fire at, or -1 for
774
+ * immediate. Mirrors the Node/Python/C-ABI `pushMidiCc`.
555
775
  */
556
- setSoloed(stripIndex, soloed) {
557
- this.mixer.setSoloed(stripIndex, soloed);
776
+ pushMidiCc(destinationId, group, channel, controller, value, renderFrame = -1) {
777
+ this.nativeExt().pushMidiCc(destinationId, group, channel, controller, value, renderFrame);
558
778
  }
559
779
  /**
560
- * Mark a strip solo-safe so it is never implied-muted by another strip's
561
- * solo. Takes effect on the next process without a graph recompile.
780
+ * Queue a MIDI panic (all-notes-off) releasing every sounding note at
781
+ * `renderFrame` (-1 = immediate). Mirrors the C-ABI `pushMidiPanic`.
562
782
  */
563
- setSoloSafe(stripIndex, soloSafe) {
564
- this.mixer.setSoloSafe(stripIndex, soloSafe);
565
- }
566
- /** Invert the polarity of the left and/or right channel of a strip. */
567
- setPolarityInvert(stripIndex, invertLeft, invertRight) {
568
- this.mixer.setPolarityInvert(stripIndex, invertLeft, invertRight);
569
- }
570
- /** Set the strip's pan law. */
571
- setPanLaw(stripIndex, panLaw) {
572
- this.mixer.setPanLaw(stripIndex, panLawCode(panLaw));
783
+ pushMidiPanic(renderFrame = -1) {
784
+ this.nativeExt().pushMidiPanic(renderFrame);
573
785
  }
574
786
  /**
575
- * Set a per-strip channel delay in samples. This changes the strip's reported
576
- * latency; recompile to re-run latency compensation.
787
+ * Remove all registered parameters (and their automation lanes). Control-thread
788
+ * only; not realtime-safe. Mirrors the C-ABI `clearParameters`.
577
789
  */
578
- setChannelDelaySamples(stripIndex, delaySamples) {
579
- this.mixer.setChannelDelaySamples(stripIndex, delaySamples);
790
+ clearParameters() {
791
+ this.nativeExt().clearParameters();
580
792
  }
581
- /** Set the strip's live VCA gain offset in dB (not persisted to the scene). */
582
- setVcaOffsetDb(stripIndex, offsetDb) {
583
- this.mixer.setVcaOffsetDb(stripIndex, offsetDb);
793
+ /** Read back the current transport state snapshot. */
794
+ getTransportState() {
795
+ return this.native.getTransportState();
584
796
  }
585
- /** Set independent left/right pan positions (dual-pan mode). */
586
- setDualPan(stripIndex, leftPan, rightPan) {
587
- this.mixer.setDualPan(stripIndex, leftPan, rightPan);
797
+ play(renderFrame = -1) {
798
+ this.native.play(renderFrame);
588
799
  }
589
- /**
590
- * Add a send to a strip after construction.
591
- *
592
- * @param stripIndex - Strip index in `[0, stripCount())`
593
- * @param id - Send id
594
- * @param destinationBusId - Destination bus id
595
- * @param sendDb - Initial send level in dB
596
- * @param timing - `'preFader'` or `'postFader'` (default: `'postFader'`)
597
- * @returns The new send's index
598
- */
599
- addSend(stripIndex, id, destinationBusId, sendDb, timing = "postFader") {
600
- return this.mixer.addSend(stripIndex, id, destinationBusId, sendDb, sendTimingCode(timing));
800
+ stop(renderFrame = -1) {
801
+ this.native.stop(renderFrame);
802
+ }
803
+ seekSample(timelineSample, renderFrame = -1) {
804
+ this.native.seekSample(timelineSample, renderFrame);
805
+ }
806
+ seekPpq(ppq, renderFrame = -1) {
807
+ this.native.seekPpq(ppq, renderFrame);
808
+ }
809
+ setTempo(bpm) {
810
+ this.native.setTempo(bpm);
811
+ }
812
+ setTimeSignature(numerator, denominator) {
813
+ this.native.setTimeSignature(numerator, denominator);
814
+ }
815
+ setLoop(startPpq, endPpq, enabled = true) {
816
+ this.native.setLoop(startPpq, endPpq, enabled);
817
+ }
818
+ addParameter(info) {
819
+ this.native.addParameter(info);
820
+ }
821
+ parameterCount() {
822
+ return this.native.parameterCount();
823
+ }
824
+ parameterInfoByIndex(index) {
825
+ return this.native.parameterInfoByIndex(index);
826
+ }
827
+ parameterInfo(id) {
828
+ return this.native.parameterInfo(id);
829
+ }
830
+ setAutomationLane(paramId, points) {
831
+ this.native.setAutomationLane(paramId, points);
832
+ }
833
+ automationLaneCount() {
834
+ return this.native.automationLaneCount();
835
+ }
836
+ setMarkers(markers) {
837
+ this.native.setMarkers(markers);
838
+ }
839
+ markerCount() {
840
+ return this.native.markerCount();
841
+ }
842
+ markerByIndex(index) {
843
+ return this.native.markerByIndex(index);
844
+ }
845
+ marker(id) {
846
+ return this.native.marker(id);
847
+ }
848
+ seekMarker(markerId, renderFrame = -1) {
849
+ this.native.seekMarker(markerId, renderFrame);
850
+ }
851
+ setLoopFromMarkers(startMarkerId, endMarkerId) {
852
+ this.native.setLoopFromMarkers(startMarkerId, endMarkerId);
853
+ }
854
+ setMetronome(config) {
855
+ this.native.setMetronome(config);
856
+ }
857
+ metronome() {
858
+ return this.native.metronome();
859
+ }
860
+ countInEndSample(startSample, bars) {
861
+ return Number(this.native.countInEndSample(startSample, bars));
862
+ }
863
+ setGraph(spec) {
864
+ this.native.setGraph(spec);
865
+ }
866
+ graphNodeCount() {
867
+ return this.native.graphNodeCount();
868
+ }
869
+ graphConnectionCount() {
870
+ return this.native.graphConnectionCount();
871
+ }
872
+ setClips(clips) {
873
+ this.native.setClips(
874
+ clips.map((clip) => ({
875
+ ...clip,
876
+ pageProvider: typeof clip.pageProvider === "object" && clip.pageProvider !== null ? clip.pageProvider.id : clip.pageProvider
877
+ }))
878
+ );
879
+ }
880
+ clipCount() {
881
+ return this.native.clipCount();
882
+ }
883
+ createClipPageProvider(numChannels, numSamples, pageFrames) {
884
+ const id = this.nativeExt().createClipPageProvider(numChannels, numSamples, pageFrames);
885
+ return new ClipPageProvider(this, id);
886
+ }
887
+ supplyClipPage(providerId, pageIndex, channels) {
888
+ this.nativeExt().supplyClipPage(providerId, pageIndex, channels);
889
+ }
890
+ clearClipPage(providerId, pageIndex) {
891
+ this.nativeExt().clearClipPage(providerId, pageIndex);
892
+ }
893
+ destroyClipPageProvider(providerId) {
894
+ this.nativeExt().destroyClipPageProvider(providerId);
895
+ }
896
+ popClipPageRequest() {
897
+ return this.nativeExt().popClipPageRequest();
898
+ }
899
+ setCaptureBuffer(numChannels, capacityFrames) {
900
+ this.native.setCaptureBuffer(numChannels, capacityFrames);
901
+ }
902
+ armCapture(armed = true) {
903
+ this.native.armCapture(armed);
904
+ }
905
+ setCapturePunch(startSample, endSample, enabled = true) {
906
+ this.native.setCapturePunch(startSample, endSample, enabled);
907
+ }
908
+ setCaptureSource(source) {
909
+ this.native.setCaptureSource(source);
910
+ }
911
+ setRecordOffsetSamples(offsetSamples) {
912
+ this.native.setRecordOffsetSamples(offsetSamples);
913
+ }
914
+ setInputMonitor(enabled, gain = 1) {
915
+ this.native.setInputMonitor(enabled, gain);
916
+ }
917
+ resetCapture() {
918
+ this.native.resetCapture();
919
+ }
920
+ captureStatus() {
921
+ return this.native.captureStatus();
601
922
  }
602
- /** Set the send level (in dB) for an existing send by index. */
603
- setSendDb(stripIndex, sendIndex, sendDb) {
604
- this.mixer.setSendDb(stripIndex, sendIndex, sendDb);
923
+ capturedAudio() {
924
+ return this.native.capturedAudio();
605
925
  }
606
- /**
607
- * Read a strip's meter snapshot at the given tap point.
608
- *
609
- * @param stripIndex - Strip index in `[0, stripCount())`
610
- * @param tap - `'preFader'` or `'postFader'` (default: `'postFader'`)
611
- */
612
- meterTap(stripIndex, tap = "postFader") {
613
- return this.mixer.meterTap(stripIndex, meterTapCode(tap));
926
+ process(channels) {
927
+ return this.native.process(channels);
614
928
  }
615
929
  /**
616
- * Read a strip's meter snapshot. Alias of {@link meterTap}, provided for
617
- * cross-binding (Node/Python) parity.
618
- *
619
- * @param stripIndex - Strip index in `[0, stripCount())`
620
- * @param tap - `'preFader'` or `'postFader'` (default: `'postFader'`)
930
+ * Allocates persistent per-channel WASM-heap scratch for the zero-copy
931
+ * `getChannelBuffer` / `processPrepared` realtime path. Call once (off the
932
+ * audio thread) before driving `processPrepared` from an AudioWorklet so the
933
+ * render callback never allocates on the C++/JS heap.
621
934
  */
622
- stripMeter(stripIndex, tap = "postFader") {
623
- return this.mixer.stripMeter(stripIndex, meterTapCode(tap));
935
+ prepareChannels(numChannels, maxFrames) {
936
+ this.native.prepareChannels(numChannels, maxFrames);
624
937
  }
625
938
  /**
626
- * Schedule sample-accurate fader automation on a strip.
627
- *
628
- * @param stripIndex - Strip index in `[0, stripCount())`
629
- * @param samplePos - Absolute samples from the start of processing
630
- * @param faderDb - Target fader level in dB
631
- * @param curve - Interpolation curve (default: `'linear'`)
939
+ * Returns a Float32Array view onto the persistent WASM-heap scratch for one
940
+ * channel (valid for up to `numFrames`). Fill it, call `processPrepared`, then
941
+ * read the same view back. Re-acquire after WASM memory growth.
632
942
  */
633
- scheduleFaderAutomation(stripIndex, samplePos, faderDb, curve = "linear") {
634
- this.mixer.scheduleFaderAutomation(stripIndex, samplePos, faderDb, automationCurveCode(curve));
943
+ getChannelBuffer(channel, numFrames) {
944
+ return this.native.getChannelBuffer(channel, numFrames);
635
945
  }
636
946
  /**
637
- * Schedule sample-accurate pan automation on a strip.
638
- *
639
- * @param stripIndex - Strip index in `[0, stripCount())`
640
- * @param samplePos - Absolute samples from the start of processing
641
- * @param pan - Target pan position
642
- * @param curve - Interpolation curve (default: `'linear'`)
947
+ * Runs the engine in place over the prepared per-channel scratch buffers.
948
+ * Allocation-free: safe to call on the AudioWorklet render thread after
949
+ * `prepareChannels`.
643
950
  */
644
- schedulePanAutomation(stripIndex, samplePos, pan, curve = "linear") {
645
- this.mixer.schedulePanAutomation(stripIndex, samplePos, pan, automationCurveCode(curve));
951
+ processPrepared(numFrames) {
952
+ this.native.processPrepared(numFrames);
646
953
  }
647
- /**
648
- * Schedule sample-accurate width automation on a strip.
649
- *
650
- * @param stripIndex - Strip index in `[0, stripCount())`
651
- * @param samplePos - Absolute samples from the start of processing
652
- * @param width - Target stereo width
653
- * @param curve - Interpolation curve (default: `'linear'`)
654
- */
655
- scheduleWidthAutomation(stripIndex, samplePos, width, curve = "linear") {
656
- this.mixer.scheduleWidthAutomation(stripIndex, samplePos, width, automationCurveCode(curve));
954
+ processWithMonitor(channels) {
955
+ return this.native.processWithMonitor(channels);
657
956
  }
658
- /**
659
- * Schedule sample-accurate send-level automation on a strip's send.
660
- *
661
- * @param stripIndex - Strip index in `[0, stripCount())`
662
- * @param sendIndex - Send index in the strip's add order
663
- * @param samplePos - Absolute samples from the start of processing
664
- * @param db - Target send level in dB
665
- * @param curve - Interpolation curve (default: `'linear'`)
666
- */
667
- scheduleSendAutomation(stripIndex, sendIndex, samplePos, db, curve = "linear") {
668
- this.mixer.scheduleSendAutomation(
669
- stripIndex,
670
- sendIndex,
671
- samplePos,
672
- db,
673
- automationCurveCode(curve)
674
- );
957
+ renderOffline(channels, blockSize = 128) {
958
+ return this.native.renderOffline(channels, blockSize);
675
959
  }
676
- /**
677
- * Read up to `maxPoints` of a strip's most recent goniometer samples
678
- * (oldest to newest).
679
- */
680
- readGoniometerLatest(stripIndex, maxPoints) {
681
- return this.mixer.readGoniometerLatest(stripIndex, maxPoints);
960
+ bounceOffline(options) {
961
+ return this.native.bounceOffline(options);
682
962
  }
683
- /** Serialize the current scene (strips, buses, sends, connections) to JSON. */
684
- toSceneJson() {
685
- return this.mixer.toSceneJson();
963
+ freezeOffline(options) {
964
+ return this.native.freezeOffline(options);
686
965
  }
687
- /** Release the underlying WASM object. Safe to call only once. */
688
- delete() {
689
- this.mixer.delete();
966
+ drainTelemetry(maxRecords = 1024) {
967
+ return this.native.drainTelemetry(maxRecords);
968
+ }
969
+ drainMeterTelemetry(maxRecords = 1024) {
970
+ return this.native.drainMeterTelemetry(maxRecords);
690
971
  }
691
- /** Alias for {@link delete}, provided for cross-binding (Node) compatibility. */
692
972
  destroy() {
693
- this.delete();
973
+ this.native.delete();
974
+ }
975
+ };
976
+ var ClipPageProvider = class {
977
+ constructor(engine, id) {
978
+ this.engine = engine;
979
+ this.id = id;
980
+ this.disposed = false;
981
+ }
982
+ supply(pageIndex, channels) {
983
+ if (this.disposed) {
984
+ throw new Error("ClipPageProvider is destroyed");
985
+ }
986
+ this.engine.supplyClipPage(this.id, pageIndex, channels);
987
+ }
988
+ clear(pageIndex) {
989
+ if (this.disposed) {
990
+ return;
991
+ }
992
+ this.engine.clearClipPage(this.id, pageIndex);
993
+ }
994
+ destroy() {
995
+ if (this.disposed) {
996
+ return;
997
+ }
998
+ this.disposed = true;
999
+ this.engine.destroyClipPageProvider(this.id);
694
1000
  }
695
1001
  };
696
1002
 
1003
+ // src/index.ts
1004
+ var module = null;
1005
+ var initPromise = null;
1006
+ async function init(options) {
1007
+ if (module) {
1008
+ return;
1009
+ }
1010
+ if (initPromise) {
1011
+ return initPromise;
1012
+ }
1013
+ initPromise = (async () => {
1014
+ try {
1015
+ const createModule = (await import("./sonare.js")).default;
1016
+ module = await createModule(options);
1017
+ setSonareModule(module);
1018
+ } catch (error) {
1019
+ initPromise = null;
1020
+ throw error;
1021
+ }
1022
+ })();
1023
+ return initPromise;
1024
+ }
1025
+ function isInitialized() {
1026
+ return module !== null;
1027
+ }
1028
+
697
1029
  // src/worklet.ts
698
1030
  var SONARE_METER_RING_HEADER_INTS = 4;
699
- var SONARE_METER_RING_RECORD_FLOATS = 6;
1031
+ var SONARE_METER_RING_RECORD_FLOATS = 7;
700
1032
  var SONARE_SPECTRUM_RING_HEADER_INTS = 5;
1033
+ var SONARE_FRAME_LANE_BASE = 16777216;
1034
+ function encodeFrameLo(frame) {
1035
+ const f = Math.max(0, Math.floor(frame));
1036
+ return f % SONARE_FRAME_LANE_BASE;
1037
+ }
1038
+ function encodeFrameHi(frame) {
1039
+ const f = Math.max(0, Math.floor(frame));
1040
+ return Math.floor(f / SONARE_FRAME_LANE_BASE);
1041
+ }
1042
+ function decodeFrame(lo, hi) {
1043
+ return hi * SONARE_FRAME_LANE_BASE + lo;
1044
+ }
701
1045
  var SONARE_ENGINE_RING_HEADER_INTS = 5;
702
1046
  var SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
703
1047
  var SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
@@ -744,6 +1088,18 @@ var SonareEngineTelemetryError = /* @__PURE__ */ ((SonareEngineTelemetryError2)
744
1088
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["SmoothedParameterCapacity"] = 13] = "SmoothedParameterCapacity";
745
1089
  return SonareEngineTelemetryError2;
746
1090
  })(SonareEngineTelemetryError || {});
1091
+ var DEFAULT_METRONOME_CONFIG = {
1092
+ beatGain: 0.35,
1093
+ accentGain: 0.7,
1094
+ clickSamples: 96
1095
+ };
1096
+ function resolveMetronomeConfig(config) {
1097
+ return {
1098
+ beatGain: config.beatGain ?? DEFAULT_METRONOME_CONFIG.beatGain,
1099
+ accentGain: config.accentGain ?? DEFAULT_METRONOME_CONFIG.accentGain,
1100
+ clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples
1101
+ };
1102
+ }
747
1103
  function toDb(value) {
748
1104
  return value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
749
1105
  }
@@ -759,6 +1115,12 @@ function isWorkletMessage(value) {
759
1115
  function isEngineCommandRecord(value) {
760
1116
  return isRecord(value) && typeof value.type === "number";
761
1117
  }
1118
+ function isEngineSyncMessage(value) {
1119
+ if (!isRecord(value) || typeof value.type !== "string") {
1120
+ return false;
1121
+ }
1122
+ return value.type === "syncClips" || value.type === "syncMarkers" || value.type === "syncMetronome" || value.type === "syncAutomation";
1123
+ }
762
1124
  function isRealtimeVoiceChangerMessage(value) {
763
1125
  if (!isRecord(value) || typeof value.type !== "string") {
764
1126
  return false;
@@ -794,7 +1156,7 @@ function readSonareMeterRingBuffer(ring, readIndex = 0) {
794
1156
  const offset = index % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
795
1157
  meters.push({
796
1158
  type: "meter",
797
- frame: ring.records[offset],
1159
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
798
1160
  peakDbL: ring.records[offset + 1],
799
1161
  peakDbR: ring.records[offset + 2],
800
1162
  rmsDbL: ring.records[offset + 3],
@@ -807,7 +1169,7 @@ function readSonareMeterRingBuffer(ring, readIndex = 0) {
807
1169
  function sonareSpectrumRingBufferByteLength(capacity, bands = 16) {
808
1170
  const clampedCapacity = Math.max(1, Math.floor(capacity));
809
1171
  const clampedBands = Math.max(1, Math.floor(bands));
810
- return SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * (2 + clampedBands) * Float32Array.BYTES_PER_ELEMENT;
1172
+ return SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * (3 + clampedBands) * Float32Array.BYTES_PER_ELEMENT;
811
1173
  }
812
1174
  function createSonareSpectrumRingBuffer(capacity = 128, bands = 16) {
813
1175
  const clampedCapacity = Math.max(1, Math.floor(capacity));
@@ -831,7 +1193,7 @@ function createSonareSpectrumRingBuffer(capacity = 128, bands = 16) {
831
1193
  }
832
1194
  function readSonareSpectrumRingBuffer(ring, readIndex = 0) {
833
1195
  const writeIndex = Atomics.load(ring.header, 0);
834
- const recordFloats = Atomics.load(ring.header, 2) || 2 + ring.bands;
1196
+ const recordFloats = Atomics.load(ring.header, 2) || 3 + ring.bands;
835
1197
  const bands = Atomics.load(ring.header, 3) || ring.bands;
836
1198
  const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
837
1199
  const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
@@ -839,8 +1201,12 @@ function readSonareSpectrumRingBuffer(ring, readIndex = 0) {
839
1201
  for (let index = firstReadable; index < writeIndex; index++) {
840
1202
  const offset = index % ring.capacity * recordFloats;
841
1203
  const values = new Float32Array(bands);
842
- values.set(ring.records.subarray(offset + 2, offset + 2 + bands));
843
- spectra.push({ type: "spectrum", frame: ring.records[offset], bands: values });
1204
+ values.set(ring.records.subarray(offset + 3, offset + 3 + bands));
1205
+ spectra.push({
1206
+ type: "spectrum",
1207
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 1]),
1208
+ bands: values
1209
+ });
844
1210
  }
845
1211
  return { nextReadIndex: writeIndex, spectra };
846
1212
  }
@@ -959,7 +1325,7 @@ function spectrumRingFromSharedBuffer(sharedBuffer, fallbackCapacity, fallbackBa
959
1325
  const existingBands = Atomics.load(header, 3);
960
1326
  const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
961
1327
  const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
962
- const recordFloats = 2 + bands;
1328
+ const recordFloats = 3 + bands;
963
1329
  const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
964
1330
  if (sharedBuffer.byteLength < minBytes) {
965
1331
  throw new Error("spectrumSharedBuffer is too small for the requested ring capacity.");
@@ -1008,8 +1374,7 @@ function writeEngineCommandRecord(view, offset, command) {
1008
1374
  view.setUint32(offset, command.type, true);
1009
1375
  view.setUint32(offset + 4, command.targetId ?? 0, true);
1010
1376
  view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
1011
- view.setFloat32(offset + 16, command.argFloat ?? 0, true);
1012
- view.setUint32(offset + 20, 0, true);
1377
+ view.setFloat64(offset + 16, command.argFloat ?? 0, true);
1013
1378
  view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
1014
1379
  }
1015
1380
  function readEngineCommandRecord(view, offset) {
@@ -1017,7 +1382,7 @@ function readEngineCommandRecord(view, offset) {
1017
1382
  type: view.getUint32(offset, true),
1018
1383
  targetId: view.getUint32(offset + 4, true),
1019
1384
  sampleTime: Number(view.getBigInt64(offset + 8, true)),
1020
- argFloat: view.getFloat32(offset + 16, true),
1385
+ argFloat: view.getFloat64(offset + 16, true),
1021
1386
  argInt: Number(view.getBigInt64(offset + 24, true))
1022
1387
  };
1023
1388
  }
@@ -1110,35 +1475,48 @@ var SonareWorkletProcessor = class {
1110
1475
  return true;
1111
1476
  }
1112
1477
  const frames = leftOut.length;
1113
- if (frames !== this.blockSize) {
1114
- return false;
1115
- }
1478
+ const usable = Math.min(frames, this.blockSize);
1116
1479
  for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
1117
1480
  const input = inputs[strip];
1118
1481
  const left = input?.[0];
1119
1482
  const right = input?.[1];
1120
1483
  const leftTarget = this.realtime.leftInputs[strip];
1121
1484
  const rightTarget = this.realtime.rightInputs[strip];
1122
- if (left && left.length === frames) {
1123
- leftTarget.set(left);
1124
- if (right && right.length === frames) {
1125
- rightTarget.set(right);
1485
+ if (left && left.length >= usable) {
1486
+ leftTarget.set(left.subarray(0, usable));
1487
+ if (right && right.length >= usable) {
1488
+ rightTarget.set(right.subarray(0, usable));
1126
1489
  } else {
1127
- rightTarget.set(left);
1490
+ rightTarget.set(left.subarray(0, usable));
1128
1491
  }
1129
1492
  } else {
1130
1493
  leftTarget.fill(0);
1131
1494
  rightTarget.fill(0);
1132
1495
  }
1133
1496
  }
1134
- this.realtime.process(frames);
1135
- leftOut.set(this.realtime.outLeft);
1136
- if (rightOut) {
1137
- rightOut.set(this.realtime.outRight);
1497
+ this.realtime.process(usable);
1498
+ if (usable === frames) {
1499
+ leftOut.set(this.realtime.outLeft.subarray(0, usable));
1500
+ if (rightOut) {
1501
+ rightOut.set(this.realtime.outRight.subarray(0, usable));
1502
+ }
1503
+ } else {
1504
+ leftOut.fill(0);
1505
+ leftOut.set(this.realtime.outLeft.subarray(0, usable));
1506
+ if (rightOut) {
1507
+ rightOut.fill(0);
1508
+ rightOut.set(this.realtime.outRight.subarray(0, usable));
1509
+ }
1138
1510
  }
1139
- this.processedFrames += frames;
1140
- this.publishMeter(this.realtime.outLeft, this.realtime.outRight);
1141
- this.publishSpectrum(this.realtime.outLeft, this.realtime.outRight);
1511
+ this.processedFrames += usable;
1512
+ this.publishMeter(
1513
+ this.realtime.outLeft.subarray(0, usable),
1514
+ this.realtime.outRight.subarray(0, usable)
1515
+ );
1516
+ this.publishSpectrum(
1517
+ this.realtime.outLeft.subarray(0, usable),
1518
+ this.realtime.outRight.subarray(0, usable)
1519
+ );
1142
1520
  return true;
1143
1521
  }
1144
1522
  receiveMessage(message) {
@@ -1224,16 +1602,14 @@ var SonareWorkletProcessor = class {
1224
1602
  }
1225
1603
  const writeIndex = Atomics.load(ring.header, 0);
1226
1604
  const offset = writeIndex % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
1227
- ring.records[offset] = meter.frame;
1605
+ ring.records[offset] = encodeFrameLo(meter.frame);
1228
1606
  ring.records[offset + 1] = meter.peakDbL;
1229
1607
  ring.records[offset + 2] = meter.peakDbR;
1230
1608
  ring.records[offset + 3] = meter.rmsDbL;
1231
1609
  ring.records[offset + 4] = meter.rmsDbR;
1232
1610
  ring.records[offset + 5] = meter.correlation;
1611
+ ring.records[offset + 6] = encodeFrameHi(meter.frame);
1233
1612
  Atomics.store(ring.header, 0, writeIndex + 1);
1234
- if (writeIndex + 1 > ring.capacity) {
1235
- Atomics.store(ring.header, 3, writeIndex + 1 - ring.capacity);
1236
- }
1237
1613
  }
1238
1614
  publishSpectrum(left, right) {
1239
1615
  if (this.spectrumIntervalFrames <= 0) {
@@ -1258,7 +1634,12 @@ var SonareWorkletProcessor = class {
1258
1634
  }
1259
1635
  computeSpectrum(left, right) {
1260
1636
  const n = Math.max(1, Math.min(left.length, right.length));
1637
+ const maxBand = Math.floor(n / 2);
1261
1638
  for (let band = 0; band < this.spectrumBands.length; band++) {
1639
+ if (band >= maxBand) {
1640
+ this.spectrumBands[band] = magnitudeToDb(0);
1641
+ continue;
1642
+ }
1262
1643
  const bin = band + 1;
1263
1644
  let real = 0;
1264
1645
  let imag = 0;
@@ -1278,19 +1659,20 @@ var SonareWorkletProcessor = class {
1278
1659
  }
1279
1660
  const writeIndex = Atomics.load(ring.header, 0);
1280
1661
  const offset = writeIndex % ring.capacity * ring.recordFloats;
1281
- ring.records[offset] = frame;
1282
- ring.records[offset + 1] = bands.length;
1283
- ring.records.set(bands.subarray(0, ring.bands), offset + 2);
1662
+ ring.records[offset] = encodeFrameLo(frame);
1663
+ ring.records[offset + 1] = encodeFrameHi(frame);
1664
+ ring.records[offset + 2] = bands.length;
1665
+ ring.records.set(bands.subarray(0, ring.bands), offset + 3);
1284
1666
  Atomics.store(ring.header, 0, writeIndex + 1);
1285
- if (writeIndex + 1 > ring.capacity) {
1286
- Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
1287
- }
1288
1667
  }
1289
1668
  };
1290
1669
  var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletProcessor {
1291
1670
  constructor(options = {}, transport) {
1292
1671
  this.closed = false;
1293
1672
  this.lastMeterFrame = Number.NEGATIVE_INFINITY;
1673
+ // Latest metronome gains/click length pushed via 'syncMetronome'. The
1674
+ // SetMetronome command only toggles enabled state; the config arrives here.
1675
+ this.metronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1294
1676
  this.sampleRate = options.sampleRate ?? 48e3;
1295
1677
  this.blockSize = options.blockSize ?? 128;
1296
1678
  this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
@@ -1307,12 +1689,12 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
1307
1689
  options.telemetrySharedBuffer,
1308
1690
  options.telemetryRingCapacity
1309
1691
  ) : void 0;
1692
+ this.meterRing = options.meterSharedBuffer ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity) : void 0;
1310
1693
  this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
1311
- this.channelScratch = new Array(this.channelCount);
1312
- this.channelScratchViews = new Array(this.channelCount);
1694
+ this.engine.prepareChannels(this.channelCount, this.blockSize);
1695
+ this.channelBuffers = new Array(this.channelCount);
1313
1696
  for (let ch = 0; ch < this.channelCount; ch++) {
1314
- this.channelScratch[ch] = new Float32Array(this.blockSize);
1315
- this.channelScratchViews[ch] = this.channelScratch[ch];
1697
+ this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1316
1698
  }
1317
1699
  }
1318
1700
  process(inputs, outputs) {
@@ -1333,34 +1715,38 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
1333
1715
  return true;
1334
1716
  }
1335
1717
  this.drainCommands();
1336
- const scratchCapacity = this.channelScratch[0]?.length ?? 0;
1337
1718
  let usableFrames = frames;
1338
- if (usableFrames > scratchCapacity) {
1719
+ if (usableFrames > this.blockSize) {
1339
1720
  if (!_SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
1340
1721
  _SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
1341
1722
  console.warn(
1342
- `SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames exceeds pre-allocated capacity ${scratchCapacity}; clamping.`
1723
+ `SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames exceeds pre-allocated capacity ${this.blockSize}; clamping.`
1343
1724
  );
1344
1725
  }
1345
- usableFrames = scratchCapacity;
1726
+ usableFrames = this.blockSize;
1727
+ }
1728
+ if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
1729
+ this.reacquireChannelBuffers();
1346
1730
  }
1347
1731
  const input = inputs[0];
1348
1732
  for (let ch = 0; ch < this.channelCount; ch++) {
1349
- const scratch = this.channelScratch[ch];
1733
+ const dst = this.channelBuffers[ch];
1350
1734
  const source = input?.[ch];
1351
1735
  if (source && source.length === usableFrames) {
1352
- scratch.set(source, 0);
1736
+ dst.set(source.subarray(0, usableFrames));
1353
1737
  } else {
1354
- scratch.fill(0, 0, usableFrames);
1738
+ dst.fill(0, 0, usableFrames);
1355
1739
  }
1356
- this.channelScratchViews[ch] = scratch.subarray(0, usableFrames);
1357
1740
  }
1358
- const processed = this.engine.process(this.channelScratchViews);
1741
+ this.engine.processPrepared(usableFrames);
1359
1742
  for (let ch = 0; ch < output.length; ch++) {
1360
1743
  const target = output[ch];
1361
- const source = processed[ch] ?? processed[0];
1744
+ const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
1362
1745
  if (source) {
1363
- target.set(source.subarray(0, target.length));
1746
+ target.set(source.subarray(0, Math.min(target.length, usableFrames)));
1747
+ if (target.length > usableFrames) {
1748
+ target.fill(0, usableFrames);
1749
+ }
1364
1750
  } else {
1365
1751
  target.fill(0);
1366
1752
  }
@@ -1369,11 +1755,41 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
1369
1755
  this.publishMeters();
1370
1756
  return true;
1371
1757
  }
1758
+ reacquireChannelBuffers() {
1759
+ for (let ch = 0; ch < this.channelCount; ch++) {
1760
+ this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1761
+ }
1762
+ }
1372
1763
  receiveCommand(command) {
1373
1764
  if (!this.closed) {
1374
1765
  this.applyCommand(command);
1375
1766
  }
1376
1767
  }
1768
+ // Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
1769
+ // global scope but OUTSIDE process() (the message-port callback), so the
1770
+ // bulk/allocating engine setters (setClips/setMarkers) are safe here — they
1771
+ // never run on the realtime render path. This is the audio-thread equivalent
1772
+ // of the engine's control-thread RtPublisher setters.
1773
+ receiveSync(message) {
1774
+ if (this.closed) {
1775
+ return;
1776
+ }
1777
+ switch (message.type) {
1778
+ case "syncClips":
1779
+ this.engine.setClips(message.clips);
1780
+ break;
1781
+ case "syncMarkers":
1782
+ this.engine.setMarkers(message.markers);
1783
+ break;
1784
+ case "syncMetronome":
1785
+ this.metronomeConfig = resolveMetronomeConfig(message.config);
1786
+ this.engine.setMetronome(message.config);
1787
+ break;
1788
+ case "syncAutomation":
1789
+ this.engine.setAutomationLane(message.paramId, message.points);
1790
+ break;
1791
+ }
1792
+ }
1377
1793
  destroy() {
1378
1794
  if (!this.closed) {
1379
1795
  this.engine.destroy();
@@ -1395,6 +1811,20 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
1395
1811
  applyCommand(command) {
1396
1812
  const sampleTime = Number(command.sampleTime ?? -1);
1397
1813
  switch (command.type) {
1814
+ case 0 /* SetParam */:
1815
+ this.engine.setParameter(
1816
+ Math.trunc(Number(command.targetId ?? 0)),
1817
+ Number(command.argFloat ?? 0),
1818
+ sampleTime
1819
+ );
1820
+ break;
1821
+ case 1 /* SetParamSmoothed */:
1822
+ this.engine.setParameterSmoothed(
1823
+ Math.trunc(Number(command.targetId ?? 0)),
1824
+ Number(command.argFloat ?? 0),
1825
+ sampleTime
1826
+ );
1827
+ break;
1398
1828
  case 2 /* TransportPlay */:
1399
1829
  this.engine.play(sampleTime);
1400
1830
  break;
@@ -1423,18 +1853,21 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
1423
1853
  case 14 /* Punch */:
1424
1854
  this.engine.setCapturePunch(
1425
1855
  Number(command.argInt ?? 0),
1426
- Math.max(0, Math.round(Number(command.argFloat ?? 0) * this.sampleRate)),
1856
+ Math.max(0, Math.round(Number(command.argFloat ?? 0))),
1427
1857
  true
1428
1858
  );
1429
1859
  break;
1430
1860
  case 15 /* SetMetronome */:
1431
1861
  this.engine.setMetronome({
1432
1862
  enabled: Boolean(command.argInt),
1433
- beatGain: 0.25,
1434
- accentGain: 0.75,
1435
- clickSamples: 64
1863
+ beatGain: this.metronomeConfig.beatGain,
1864
+ accentGain: this.metronomeConfig.accentGain,
1865
+ clickSamples: this.metronomeConfig.clickSamples
1436
1866
  });
1437
1867
  break;
1868
+ case 17 /* SeekMarker */:
1869
+ this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
1870
+ break;
1438
1871
  default:
1439
1872
  this.publishTelemetryRecord({
1440
1873
  type: 1 /* Error */,
@@ -1461,7 +1894,7 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
1461
1894
  this.transport?.postMessage?.(record);
1462
1895
  }
1463
1896
  publishMeters() {
1464
- if (!this.transport || this.meterIntervalFrames <= 0) {
1897
+ if (this.meterIntervalFrames <= 0 || !this.transport && !this.meterRing) {
1465
1898
  return;
1466
1899
  }
1467
1900
  for (const item of this.engine.drainMeterTelemetry(64)) {
@@ -1470,9 +1903,29 @@ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletPr
1470
1903
  continue;
1471
1904
  }
1472
1905
  this.lastMeterFrame = meter.frame;
1473
- this.transport.onMeter?.(meter);
1474
- this.transport.postMessage?.(meter);
1906
+ if (this.meterRing) {
1907
+ this.writeMeterRing(meter);
1908
+ } else {
1909
+ this.transport?.onMeter?.(meter);
1910
+ this.transport?.postMessage?.(meter);
1911
+ }
1912
+ }
1913
+ }
1914
+ writeMeterRing(meter) {
1915
+ const ring = this.meterRing;
1916
+ if (!ring) {
1917
+ return;
1475
1918
  }
1919
+ const writeIndex = Atomics.load(ring.header, 0);
1920
+ const offset = writeIndex % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
1921
+ ring.records[offset] = encodeFrameLo(meter.frame);
1922
+ ring.records[offset + 1] = meter.peakDbL;
1923
+ ring.records[offset + 2] = meter.peakDbR;
1924
+ ring.records[offset + 3] = meter.rmsDbL;
1925
+ ring.records[offset + 4] = meter.rmsDbR;
1926
+ ring.records[offset + 5] = meter.correlation;
1927
+ ring.records[offset + 6] = encodeFrameHi(meter.frame);
1928
+ Atomics.store(ring.header, 0, writeIndex + 1);
1476
1929
  }
1477
1930
  commandRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
1478
1931
  const ring = engineRingFromSharedBuffer(
@@ -1495,6 +1948,7 @@ _SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = false;
1495
1948
  var SonareRealtimeEngineWorkletProcessor = _SonareRealtimeEngineWorkletProcessor;
1496
1949
  var SonareRtRealtimeEngineRuntime = class {
1497
1950
  constructor(options) {
1951
+ this.metronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1498
1952
  this.closed = false;
1499
1953
  this.module = options.module;
1500
1954
  this.memory = options.memory;
@@ -1576,6 +2030,49 @@ var SonareRtRealtimeEngineRuntime = class {
1576
2030
  this.publishTelemetry();
1577
2031
  return true;
1578
2032
  }
2033
+ receiveCommand(command) {
2034
+ if (!this.closed) {
2035
+ this.applyCommand(command);
2036
+ }
2037
+ }
2038
+ // Out-of-band control sync for the sonare-rt runtime. The sonare-rt C ABI
2039
+ // (src/wasm/rt_bindings.cpp) exposes set_metronome_enabled and seek_marker but
2040
+ // NOT set_clips / set_markers, so clip/marker mutations cannot be applied to a
2041
+ // live sonare-rt engine. We honor the metronome config and surface a clear
2042
+ // telemetry error for the unsupported clip/marker paths instead of silently
2043
+ // dropping them. The default 'embind' runtime wires all three fully.
2044
+ receiveSync(message) {
2045
+ if (this.closed) {
2046
+ return;
2047
+ }
2048
+ switch (message.type) {
2049
+ case "syncMetronome":
2050
+ this.metronomeConfig = resolveMetronomeConfig(message.config);
2051
+ this.module._sonare_rt_engine_set_metronome_enabled(
2052
+ this.engine,
2053
+ message.config.enabled ? 1 : 0,
2054
+ this.metronomeConfig.beatGain,
2055
+ this.metronomeConfig.accentGain,
2056
+ this.metronomeConfig.clickSamples
2057
+ );
2058
+ break;
2059
+ case "syncClips":
2060
+ case "syncMarkers":
2061
+ case "syncAutomation":
2062
+ if (this.telemetryRing) {
2063
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
2064
+ type: 1 /* Error */,
2065
+ error: 7 /* UnknownTarget */,
2066
+ renderFrame: 0,
2067
+ timelineSample: 0,
2068
+ audibleTimelineSample: 0,
2069
+ graphLatencySamplesQ8: 0,
2070
+ value: 0
2071
+ });
2072
+ }
2073
+ break;
2074
+ }
2075
+ }
1579
2076
  destroy() {
1580
2077
  if (this.closed) {
1581
2078
  return;
@@ -1611,6 +2108,20 @@ var SonareRtRealtimeEngineRuntime = class {
1611
2108
  applyCommand(command) {
1612
2109
  const sampleTime = toBigInt64(command.sampleTime, -1n);
1613
2110
  switch (command.type) {
2111
+ case 0 /* SetParam */:
2112
+ case 1 /* SetParamSmoothed */:
2113
+ if (this.telemetryRing) {
2114
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
2115
+ type: 1 /* Error */,
2116
+ error: 7 /* UnknownTarget */,
2117
+ renderFrame: 0,
2118
+ timelineSample: 0,
2119
+ audibleTimelineSample: 0,
2120
+ graphLatencySamplesQ8: 0,
2121
+ value: Number(command.type)
2122
+ });
2123
+ }
2124
+ break;
1614
2125
  case 2 /* TransportPlay */:
1615
2126
  this.module._sonare_rt_engine_play(this.engine, sampleTime);
1616
2127
  break;
@@ -1649,7 +2160,7 @@ var SonareRtRealtimeEngineRuntime = class {
1649
2160
  this.module._sonare_rt_engine_set_capture_punch(
1650
2161
  this.engine,
1651
2162
  toBigInt64(command.argInt, 0n),
1652
- BigInt(Math.trunc(Number(command.argFloat ?? 0) * this.sampleRate)),
2163
+ BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
1653
2164
  1
1654
2165
  );
1655
2166
  break;
@@ -1657,9 +2168,9 @@ var SonareRtRealtimeEngineRuntime = class {
1657
2168
  this.module._sonare_rt_engine_set_metronome_enabled(
1658
2169
  this.engine,
1659
2170
  command.argInt ? 1 : 0,
1660
- 0.25,
1661
- 0.75,
1662
- 64
2171
+ this.metronomeConfig.beatGain,
2172
+ this.metronomeConfig.accentGain,
2173
+ this.metronomeConfig.clickSamples
1663
2174
  );
1664
2175
  break;
1665
2176
  case 17 /* SeekMarker */:
@@ -1734,8 +2245,9 @@ var SonareRtRealtimeEngineRuntime = class {
1734
2245
  }
1735
2246
  };
1736
2247
  var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1737
- constructor(node, capabilities, commandRing, telemetryRing) {
2248
+ constructor(node, capabilities, commandRing, telemetryRing, meterRing) {
1738
2249
  this.telemetryReadIndex = 0;
2250
+ this.meterReadIndex = 0;
1739
2251
  this.telemetryListeners = /* @__PURE__ */ new Set();
1740
2252
  this.meterListeners = /* @__PURE__ */ new Set();
1741
2253
  this.destroyed = false;
@@ -1743,6 +2255,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1743
2255
  this.capabilities = capabilities;
1744
2256
  this.commandRing = commandRing;
1745
2257
  this.telemetryRing = telemetryRing;
2258
+ this.meterRing = meterRing;
1746
2259
  this.ready = new Promise((resolve, reject) => {
1747
2260
  this.resolveReady = resolve;
1748
2261
  this.rejectReady = reject;
@@ -1791,6 +2304,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1791
2304
  }
1792
2305
  const commandRing = mode === "sab" ? createSonareEngineCommandRingBuffer(options.commandRingCapacity ?? 128) : void 0;
1793
2306
  const telemetryRing = mode === "sab" ? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128) : void 0;
2307
+ const meterRing = mode === "sab" && runtimeTarget === "embind" ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128) : void 0;
1794
2308
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1795
2309
  const processorOptions = {
1796
2310
  runtimeTarget,
@@ -1802,7 +2316,9 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1802
2316
  commandSharedBuffer: commandRing?.sharedBuffer,
1803
2317
  commandRingCapacity: commandRing?.capacity,
1804
2318
  telemetrySharedBuffer: telemetryRing?.sharedBuffer,
1805
- telemetryRingCapacity: telemetryRing?.capacity
2319
+ telemetryRingCapacity: telemetryRing?.capacity,
2320
+ meterSharedBuffer: meterRing?.sharedBuffer,
2321
+ meterRingCapacity: meterRing?.capacity
1806
2322
  };
1807
2323
  const factory = options.nodeFactory ?? ((ctx, name, nodeOptions) => new AudioWorkletNode(ctx, name, nodeOptions));
1808
2324
  const node = factory(context, processorName, {
@@ -1825,7 +2341,8 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1825
2341
  degradedReason
1826
2342
  },
1827
2343
  commandRing,
1828
- telemetryRing
2344
+ telemetryRing,
2345
+ meterRing
1829
2346
  );
1830
2347
  }
1831
2348
  play(sampleTime = -1) {
@@ -1869,6 +2386,20 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1869
2386
  }
1870
2387
  return read.telemetry;
1871
2388
  }
2389
+ // Drains any meters published into the SAB meter ring (embind SAB mode) and
2390
+ // forwards them to onMeter listeners. In postMessage mode meters arrive via
2391
+ // node.port.onmessage instead, so this is a no-op then.
2392
+ pollMeters() {
2393
+ if (!this.meterRing) {
2394
+ return [];
2395
+ }
2396
+ const read = readSonareMeterRingBuffer(this.meterRing, this.meterReadIndex);
2397
+ this.meterReadIndex = read.nextReadIndex;
2398
+ for (const meter of read.meters) {
2399
+ this.emitMeter(meter);
2400
+ }
2401
+ return read.meters;
2402
+ }
1872
2403
  onTelemetry(callback) {
1873
2404
  this.telemetryListeners.add(callback);
1874
2405
  return () => {
@@ -1983,10 +2514,14 @@ var SonareEngine = class _SonareEngine {
1983
2514
  });
1984
2515
  }
1985
2516
  setParam(nodeId, param, value) {
1986
- void nodeId;
1987
- void param;
1988
- void value;
1989
- return false;
2517
+ const paramId = this.resolveParamId(nodeId, param);
2518
+ this.offlineEngine.setParameter(paramId, value);
2519
+ return this.realtimeNode.sendCommand({
2520
+ type: 0 /* SetParam */,
2521
+ targetId: paramId,
2522
+ sampleTime: -1,
2523
+ argFloat: value
2524
+ });
1990
2525
  }
1991
2526
  scheduleParam(nodeId, param, ppq, value, curve = "linear") {
1992
2527
  const paramId = this.resolveParamId(nodeId, param);
@@ -1995,6 +2530,7 @@ var SonareEngine = class _SonareEngine {
1995
2530
  lane.sort((a, b) => a.ppq - b.ppq);
1996
2531
  this.automationLanes.set(paramId, lane);
1997
2532
  this.offlineEngine.setAutomationLane(paramId, lane);
2533
+ this.postSync({ type: "syncAutomation", paramId, points: lane });
1998
2534
  }
1999
2535
  addAutomationPoint(laneId, ppq, value, curve = "linear") {
2000
2536
  this.scheduleParam("", laneId, ppq, value, curve);
@@ -2010,7 +2546,9 @@ var SonareEngine = class _SonareEngine {
2010
2546
  void target;
2011
2547
  void solo;
2012
2548
  void mute;
2013
- return false;
2549
+ throw new Error(
2550
+ "SonareEngine.setSoloMute is not supported: solo/mute is a Mixer feature; use Mixer.setSoloed(stripIndex, ...) / Mixer.setMuted(stripIndex, ...) instead."
2551
+ );
2014
2552
  }
2015
2553
  addClip(trackId, buffer, startPpq, opts = {}) {
2016
2554
  const id = opts.id ?? this.nextClipId++;
@@ -2046,11 +2584,12 @@ var SonareEngine = class _SonareEngine {
2046
2584
  type: 14 /* Punch */,
2047
2585
  sampleTime: -1,
2048
2586
  argInt: inSample,
2049
- argFloat: outPpq
2587
+ argFloat: outSample
2050
2588
  });
2051
2589
  }
2052
2590
  setMetronome(opts) {
2053
2591
  this.offlineEngine.setMetronome(opts);
2592
+ this.postSync({ type: "syncMetronome", config: opts });
2054
2593
  this.realtimeNode.sendCommand({
2055
2594
  type: 15 /* SetMetronome */,
2056
2595
  sampleTime: -1,
@@ -2065,7 +2604,11 @@ var SonareEngine = class _SonareEngine {
2065
2604
  }
2066
2605
  seekMarker(markerId) {
2067
2606
  this.offlineEngine.seekMarker(markerId);
2068
- return false;
2607
+ return this.realtimeNode.sendCommand({
2608
+ type: 17 /* SeekMarker */,
2609
+ targetId: markerId,
2610
+ sampleTime: -1
2611
+ });
2069
2612
  }
2070
2613
  async renderOffline(totalFrames) {
2071
2614
  const frames = Math.max(0, Math.floor(totalFrames));
@@ -2084,6 +2627,9 @@ var SonareEngine = class _SonareEngine {
2084
2627
  pollTelemetry() {
2085
2628
  return this.realtimeNode.pollTelemetry();
2086
2629
  }
2630
+ pollMeters() {
2631
+ return this.realtimeNode.pollMeters();
2632
+ }
2087
2633
  destroy() {
2088
2634
  if (this.destroyed) {
2089
2635
  return;
@@ -2095,10 +2641,23 @@ var SonareEngine = class _SonareEngine {
2095
2641
  this.offlineEngine.destroy();
2096
2642
  }
2097
2643
  syncClips() {
2098
- this.offlineEngine.setClips(Array.from(this.clips.values()));
2644
+ const clips = Array.from(this.clips.values());
2645
+ this.offlineEngine.setClips(clips);
2646
+ this.postSync({ type: "syncClips", clips });
2099
2647
  }
2100
2648
  syncMarkers() {
2101
- this.offlineEngine.setMarkers(Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq));
2649
+ const markers = Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq);
2650
+ this.offlineEngine.setMarkers(markers);
2651
+ this.postSync({ type: "syncMarkers", markers });
2652
+ }
2653
+ // Posts an out-of-band control-sync message to the worklet engine processor.
2654
+ // Sync messages use a string `type` so the worklet's message handler routes
2655
+ // them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
2656
+ postSync(message) {
2657
+ if (this.destroyed) {
2658
+ return;
2659
+ }
2660
+ this.realtimeNode.node.port.postMessage(message);
2102
2661
  }
2103
2662
  resolveParamId(nodeId, param) {
2104
2663
  if (typeof param === "number") {
@@ -2169,6 +2728,9 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
2169
2728
  if (this.destroyed || !output || output.length === 0) {
2170
2729
  return !this.destroyed;
2171
2730
  }
2731
+ if (this.monoInput.byteLength === 0) {
2732
+ this.reacquireBuffers();
2733
+ }
2172
2734
  const input = inputs[0];
2173
2735
  const requestedFrames = output[0]?.length ?? 0;
2174
2736
  const requestedChannels = Math.min(this.channelCount, output.length);
@@ -2220,6 +2782,19 @@ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChan
2220
2782
  this.destroyed = true;
2221
2783
  this.changer.delete();
2222
2784
  }
2785
+ // Re-acquires the cached WASM-heap views after a memory-growth detachment.
2786
+ // The underlying C++ vectors are pre-warmed (ensure_*_capacity ran at prepare
2787
+ // time), so getMono*/getPlanar* return fresh views onto the SAME storage
2788
+ // without reallocating it.
2789
+ reacquireBuffers() {
2790
+ this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
2791
+ this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
2792
+ if (this.channelCount > 1) {
2793
+ for (let ch = 0; ch < this.channelCount; ch++) {
2794
+ this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
2795
+ }
2796
+ }
2797
+ }
2223
2798
  /**
2224
2799
  * Returns the number of frames we can actually process given the
2225
2800
  * pre-allocated capacity. If the host requests more frames than the
@@ -2343,6 +2918,10 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
2343
2918
  const onMessage = (event) => {
2344
2919
  if (isEngineCommandRecord(event.data)) {
2345
2920
  this.bridge?.receiveCommand(event.data);
2921
+ this.rtBridge?.receiveCommand(event.data);
2922
+ } else if (isEngineSyncMessage(event.data)) {
2923
+ this.bridge?.receiveSync(event.data);
2924
+ this.rtBridge?.receiveSync(event.data);
2346
2925
  }
2347
2926
  };
2348
2927
  if (port?.addEventListener) {
@@ -2421,6 +3000,9 @@ export {
2421
3000
  createSonareEngineTelemetryRingBuffer,
2422
3001
  createSonareMeterRingBuffer,
2423
3002
  createSonareSpectrumRingBuffer,
3003
+ decodeFrame,
3004
+ encodeFrameHi,
3005
+ encodeFrameLo,
2424
3006
  init,
2425
3007
  isInitialized,
2426
3008
  popSonareEngineCommandRingBuffer,