@libraz/libsonare 1.2.1 → 1.2.3

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.
package/dist/worklet.js CHANGED
@@ -29,6 +29,21 @@ function panLawCode(panLaw) {
29
29
  return 0;
30
30
  }
31
31
  }
32
+ function panModeCode(panMode) {
33
+ if (typeof panMode === "number") {
34
+ return panMode;
35
+ }
36
+ switch (panMode) {
37
+ case "stereoPan":
38
+ case "stereo-pan":
39
+ return 1;
40
+ case "dualPan":
41
+ case "dual-pan":
42
+ return 2;
43
+ default:
44
+ return 0;
45
+ }
46
+ }
32
47
  function meterTapCode(tap) {
33
48
  return tap === "preFader" || tap === 0 ? 0 : 1;
34
49
  }
@@ -214,6 +229,31 @@ var RealtimeEngine = class {
214
229
  process(channels) {
215
230
  return this.native.process(channels);
216
231
  }
232
+ /**
233
+ * Allocates persistent per-channel WASM-heap scratch for the zero-copy
234
+ * `getChannelBuffer` / `processPrepared` realtime path. Call once (off the
235
+ * audio thread) before driving `processPrepared` from an AudioWorklet so the
236
+ * render callback never allocates on the C++/JS heap.
237
+ */
238
+ prepareChannels(numChannels, maxFrames) {
239
+ this.native.prepareChannels(numChannels, maxFrames);
240
+ }
241
+ /**
242
+ * Returns a Float32Array view onto the persistent WASM-heap scratch for one
243
+ * channel (valid for up to `numFrames`). Fill it, call `processPrepared`, then
244
+ * read the same view back. Re-acquire after WASM memory growth.
245
+ */
246
+ getChannelBuffer(channel, numFrames) {
247
+ return this.native.getChannelBuffer(channel, numFrames);
248
+ }
249
+ /**
250
+ * Runs the engine in place over the prepared per-channel scratch buffers.
251
+ * Allocation-free: safe to call on the AudioWorklet render thread after
252
+ * `prepareChannels`.
253
+ */
254
+ processPrepared(numFrames) {
255
+ this.native.processPrepared(numFrames);
256
+ }
217
257
  processWithMonitor(channels) {
218
258
  return this.native.processWithMonitor(channels);
219
259
  }
@@ -236,6 +276,141 @@ var RealtimeEngine = class {
236
276
  this.native.delete();
237
277
  }
238
278
  };
279
+ var RealtimeVoiceChanger = class {
280
+ constructor(config = "neutral-monitor") {
281
+ if (!module) {
282
+ throw new Error("Module not initialized. Call init() first.");
283
+ }
284
+ this.changer = module.createRealtimeVoiceChanger(config);
285
+ }
286
+ prepare(sampleRate, maxBlockSize = 128, channels = 1) {
287
+ this.changer.prepare(sampleRate, maxBlockSize, channels);
288
+ }
289
+ reset() {
290
+ this.changer.reset();
291
+ }
292
+ setConfig(config) {
293
+ this.changer.setConfig(config);
294
+ }
295
+ configJson() {
296
+ return this.changer.configJson();
297
+ }
298
+ latencySamples() {
299
+ return this.changer.latencySamples();
300
+ }
301
+ processMono(samples) {
302
+ return this.changer.processMono(samples);
303
+ }
304
+ processMonoInto(samples, output) {
305
+ this.changer.processMonoInto(samples, output);
306
+ }
307
+ processInterleaved(samples, channels) {
308
+ return this.changer.processInterleaved(samples, channels);
309
+ }
310
+ processInterleavedInto(samples, channels, output) {
311
+ this.changer.processInterleavedInto(samples, channels, output);
312
+ }
313
+ /**
314
+ * Acquire a typed-memory view onto the WASM heap for mono input.
315
+ *
316
+ * Write your input samples into the returned `Float32Array` directly (e.g.
317
+ * via `input.set(source)`); no copy crosses the JS↔C++ bridge until
318
+ * {@link processPreparedMono} is called. The view is owned by this
319
+ * RealtimeVoiceChanger and becomes invalid after {@link delete}; it may
320
+ * also be invalidated if you later call this method with a larger
321
+ * `numSamples` value (the underlying buffer may be reallocated).
322
+ */
323
+ getMonoInputBuffer(numSamples) {
324
+ return this.changer.getMonoInputBuffer(numSamples);
325
+ }
326
+ /** Mono output view counterpart to {@link getMonoInputBuffer}. */
327
+ getMonoOutputBuffer(numSamples) {
328
+ return this.changer.getMonoOutputBuffer(numSamples);
329
+ }
330
+ /**
331
+ * Process the previously-acquired mono input buffer in place. The output
332
+ * appears in the buffer returned by {@link getMonoOutputBuffer}. No JS↔C++
333
+ * sample-level crossings happen on this call — it just hands control to
334
+ * the underlying DSP on already-on-heap data.
335
+ */
336
+ processPreparedMono(numSamples) {
337
+ this.changer.processPreparedMono(numSamples);
338
+ }
339
+ /** Interleaved input view (layout L0,R0,L1,R1,...). */
340
+ getInterleavedInputBuffer(numFrames, numChannels) {
341
+ return this.changer.getInterleavedInputBuffer(numFrames, numChannels);
342
+ }
343
+ /** Interleaved output view counterpart. */
344
+ getInterleavedOutputBuffer(numFrames, numChannels) {
345
+ return this.changer.getInterleavedOutputBuffer(numFrames, numChannels);
346
+ }
347
+ /**
348
+ * Process the previously-acquired interleaved buffer in place. Output
349
+ * appears in the buffer returned by {@link getInterleavedOutputBuffer}.
350
+ */
351
+ processPreparedInterleaved(numFrames, numChannels) {
352
+ this.changer.processPreparedInterleaved(numFrames, numChannels);
353
+ }
354
+ /**
355
+ * Planar-channel input/output view (one Float32Array per channel). Matches
356
+ * AudioWorklet's native layout; processing happens in place.
357
+ */
358
+ getPlanarChannelBuffer(channel, numFrames) {
359
+ return this.changer.getPlanarChannelBuffer(channel, numFrames);
360
+ }
361
+ /**
362
+ * Process the previously-acquired planar channel buffers in place. Each
363
+ * channel must have been obtained from {@link getPlanarChannelBuffer}
364
+ * with the same `numFrames`. Output replaces input in the same buffers.
365
+ */
366
+ processPreparedPlanar(numFrames) {
367
+ this.changer.processPreparedPlanar(numFrames);
368
+ }
369
+ /**
370
+ * Convenience factory for the mono zero-copy path: returns the input/output
371
+ * heap views plus a `process()` thunk wired to the same `numSamples`. The
372
+ * views are reused across calls and become invalid after {@link delete}.
373
+ */
374
+ createRealtimeMonoBuffer(numSamples) {
375
+ const input = this.getMonoInputBuffer(numSamples);
376
+ const output = this.getMonoOutputBuffer(numSamples);
377
+ return {
378
+ input,
379
+ output,
380
+ process: () => this.processPreparedMono(numSamples)
381
+ };
382
+ }
383
+ /** Same as {@link createRealtimeMonoBuffer} but for interleaved I/O. */
384
+ createRealtimeInterleavedBuffer(numFrames, numChannels) {
385
+ const input = this.getInterleavedInputBuffer(numFrames, numChannels);
386
+ const output = this.getInterleavedOutputBuffer(numFrames, numChannels);
387
+ return {
388
+ input,
389
+ output,
390
+ channels: numChannels,
391
+ process: () => this.processPreparedInterleaved(numFrames, numChannels)
392
+ };
393
+ }
394
+ /**
395
+ * Convenience factory for the planar zero-copy path. Acquires one
396
+ * heap-backed Float32Array per channel and returns a `process()` thunk
397
+ * wired to the same `numFrames`. Buffers are reused across calls and
398
+ * become invalid after {@link delete}.
399
+ */
400
+ createRealtimePlanarBuffer(numFrames, numChannels) {
401
+ const channels = [];
402
+ for (let ch = 0; ch < numChannels; ch++) {
403
+ channels.push(this.getPlanarChannelBuffer(ch, numFrames));
404
+ }
405
+ return {
406
+ channels,
407
+ process: () => this.processPreparedPlanar(numFrames)
408
+ };
409
+ }
410
+ delete() {
411
+ this.changer.delete();
412
+ }
413
+ };
239
414
  var Mixer = class _Mixer {
240
415
  constructor(mixer) {
241
416
  this.mixer = mixer;
@@ -379,6 +554,26 @@ var Mixer = class _Mixer {
379
554
  vcaGroupCount() {
380
555
  return this.mixer.vcaGroupCount();
381
556
  }
557
+ /** Set the strip's input trim in dB. */
558
+ setInputTrimDb(stripIndex, db) {
559
+ this.mixer.setInputTrimDb(stripIndex, db);
560
+ }
561
+ /** Set the strip's fader level in dB. */
562
+ setFaderDb(stripIndex, db) {
563
+ this.mixer.setFaderDb(stripIndex, db);
564
+ }
565
+ /** Set the strip's pan position. */
566
+ setPan(stripIndex, pan, panMode = 0) {
567
+ this.mixer.setPan(stripIndex, pan, panModeCode(panMode));
568
+ }
569
+ /** Set the strip's stereo width. */
570
+ setWidth(stripIndex, width) {
571
+ this.mixer.setWidth(stripIndex, width);
572
+ }
573
+ /** Set the strip's mute state. */
574
+ setMuted(stripIndex, muted) {
575
+ this.mixer.setMuted(stripIndex, muted);
576
+ }
382
577
  /**
383
578
  * Set a strip's solo state. Takes effect on the next process without a
384
579
  * graph recompile.
@@ -526,8 +721,20 @@ var Mixer = class _Mixer {
526
721
 
527
722
  // src/worklet.ts
528
723
  var SONARE_METER_RING_HEADER_INTS = 4;
529
- var SONARE_METER_RING_RECORD_FLOATS = 6;
724
+ var SONARE_METER_RING_RECORD_FLOATS = 7;
530
725
  var SONARE_SPECTRUM_RING_HEADER_INTS = 5;
726
+ var SONARE_FRAME_LANE_BASE = 16777216;
727
+ function encodeFrameLo(frame) {
728
+ const f = Math.max(0, Math.floor(frame));
729
+ return f % SONARE_FRAME_LANE_BASE;
730
+ }
731
+ function encodeFrameHi(frame) {
732
+ const f = Math.max(0, Math.floor(frame));
733
+ return Math.floor(f / SONARE_FRAME_LANE_BASE);
734
+ }
735
+ function decodeFrame(lo, hi) {
736
+ return hi * SONARE_FRAME_LANE_BASE + lo;
737
+ }
531
738
  var SONARE_ENGINE_RING_HEADER_INTS = 5;
532
739
  var SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
533
740
  var SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
@@ -574,6 +781,18 @@ var SonareEngineTelemetryError = /* @__PURE__ */ ((SonareEngineTelemetryError2)
574
781
  SonareEngineTelemetryError2[SonareEngineTelemetryError2["SmoothedParameterCapacity"] = 13] = "SmoothedParameterCapacity";
575
782
  return SonareEngineTelemetryError2;
576
783
  })(SonareEngineTelemetryError || {});
784
+ var DEFAULT_METRONOME_CONFIG = {
785
+ beatGain: 0.35,
786
+ accentGain: 0.7,
787
+ clickSamples: 96
788
+ };
789
+ function resolveMetronomeConfig(config) {
790
+ return {
791
+ beatGain: config.beatGain ?? DEFAULT_METRONOME_CONFIG.beatGain,
792
+ accentGain: config.accentGain ?? DEFAULT_METRONOME_CONFIG.accentGain,
793
+ clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples
794
+ };
795
+ }
577
796
  function toDb(value) {
578
797
  return value > 0 ? 20 * Math.log10(value) : Number.NEGATIVE_INFINITY;
579
798
  }
@@ -589,6 +808,18 @@ function isWorkletMessage(value) {
589
808
  function isEngineCommandRecord(value) {
590
809
  return isRecord(value) && typeof value.type === "number";
591
810
  }
811
+ function isEngineSyncMessage(value) {
812
+ if (!isRecord(value) || typeof value.type !== "string") {
813
+ return false;
814
+ }
815
+ return value.type === "syncClips" || value.type === "syncMarkers" || value.type === "syncMetronome" || value.type === "syncAutomation";
816
+ }
817
+ function isRealtimeVoiceChangerMessage(value) {
818
+ if (!isRecord(value) || typeof value.type !== "string") {
819
+ return false;
820
+ }
821
+ return value.type === "setConfig" || value.type === "reset" || value.type === "destroy";
822
+ }
592
823
  function isEngineTelemetryRecord(value) {
593
824
  return isRecord(value) && typeof value.type === "number" && typeof value.error === "number" && typeof value.renderFrame === "number" && typeof value.timelineSample === "number" && typeof value.audibleTimelineSample === "number" && typeof value.graphLatencySamplesQ8 === "number" && typeof value.value === "number";
594
825
  }
@@ -618,7 +849,7 @@ function readSonareMeterRingBuffer(ring, readIndex = 0) {
618
849
  const offset = index % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
619
850
  meters.push({
620
851
  type: "meter",
621
- frame: ring.records[offset],
852
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
622
853
  peakDbL: ring.records[offset + 1],
623
854
  peakDbR: ring.records[offset + 2],
624
855
  rmsDbL: ring.records[offset + 3],
@@ -631,7 +862,7 @@ function readSonareMeterRingBuffer(ring, readIndex = 0) {
631
862
  function sonareSpectrumRingBufferByteLength(capacity, bands = 16) {
632
863
  const clampedCapacity = Math.max(1, Math.floor(capacity));
633
864
  const clampedBands = Math.max(1, Math.floor(bands));
634
- return SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * (2 + clampedBands) * Float32Array.BYTES_PER_ELEMENT;
865
+ return SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT + clampedCapacity * (3 + clampedBands) * Float32Array.BYTES_PER_ELEMENT;
635
866
  }
636
867
  function createSonareSpectrumRingBuffer(capacity = 128, bands = 16) {
637
868
  const clampedCapacity = Math.max(1, Math.floor(capacity));
@@ -655,7 +886,7 @@ function createSonareSpectrumRingBuffer(capacity = 128, bands = 16) {
655
886
  }
656
887
  function readSonareSpectrumRingBuffer(ring, readIndex = 0) {
657
888
  const writeIndex = Atomics.load(ring.header, 0);
658
- const recordFloats = Atomics.load(ring.header, 2) || 2 + ring.bands;
889
+ const recordFloats = Atomics.load(ring.header, 2) || 3 + ring.bands;
659
890
  const bands = Atomics.load(ring.header, 3) || ring.bands;
660
891
  const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
661
892
  const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
@@ -663,8 +894,12 @@ function readSonareSpectrumRingBuffer(ring, readIndex = 0) {
663
894
  for (let index = firstReadable; index < writeIndex; index++) {
664
895
  const offset = index % ring.capacity * recordFloats;
665
896
  const values = new Float32Array(bands);
666
- values.set(ring.records.subarray(offset + 2, offset + 2 + bands));
667
- spectra.push({ type: "spectrum", frame: ring.records[offset], bands: values });
897
+ values.set(ring.records.subarray(offset + 3, offset + 3 + bands));
898
+ spectra.push({
899
+ type: "spectrum",
900
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 1]),
901
+ bands: values
902
+ });
668
903
  }
669
904
  return { nextReadIndex: writeIndex, spectra };
670
905
  }
@@ -783,7 +1018,7 @@ function spectrumRingFromSharedBuffer(sharedBuffer, fallbackCapacity, fallbackBa
783
1018
  const existingBands = Atomics.load(header, 3);
784
1019
  const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
785
1020
  const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
786
- const recordFloats = 2 + bands;
1021
+ const recordFloats = 3 + bands;
787
1022
  const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
788
1023
  if (sharedBuffer.byteLength < minBytes) {
789
1024
  throw new Error("spectrumSharedBuffer is too small for the requested ring capacity.");
@@ -832,8 +1067,7 @@ function writeEngineCommandRecord(view, offset, command) {
832
1067
  view.setUint32(offset, command.type, true);
833
1068
  view.setUint32(offset + 4, command.targetId ?? 0, true);
834
1069
  view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
835
- view.setFloat32(offset + 16, command.argFloat ?? 0, true);
836
- view.setUint32(offset + 20, 0, true);
1070
+ view.setFloat64(offset + 16, command.argFloat ?? 0, true);
837
1071
  view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
838
1072
  }
839
1073
  function readEngineCommandRecord(view, offset) {
@@ -841,7 +1075,7 @@ function readEngineCommandRecord(view, offset) {
841
1075
  type: view.getUint32(offset, true),
842
1076
  targetId: view.getUint32(offset + 4, true),
843
1077
  sampleTime: Number(view.getBigInt64(offset + 8, true)),
844
- argFloat: view.getFloat32(offset + 16, true),
1078
+ argFloat: view.getFloat64(offset + 16, true),
845
1079
  argInt: Number(view.getBigInt64(offset + 24, true))
846
1080
  };
847
1081
  }
@@ -934,35 +1168,48 @@ var SonareWorkletProcessor = class {
934
1168
  return true;
935
1169
  }
936
1170
  const frames = leftOut.length;
937
- if (frames !== this.blockSize) {
938
- return false;
939
- }
1171
+ const usable = Math.min(frames, this.blockSize);
940
1172
  for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
941
1173
  const input = inputs[strip];
942
1174
  const left = input?.[0];
943
1175
  const right = input?.[1];
944
1176
  const leftTarget = this.realtime.leftInputs[strip];
945
1177
  const rightTarget = this.realtime.rightInputs[strip];
946
- if (left && left.length === frames) {
947
- leftTarget.set(left);
948
- if (right && right.length === frames) {
949
- rightTarget.set(right);
1178
+ if (left && left.length >= usable) {
1179
+ leftTarget.set(left.subarray(0, usable));
1180
+ if (right && right.length >= usable) {
1181
+ rightTarget.set(right.subarray(0, usable));
950
1182
  } else {
951
- rightTarget.set(left);
1183
+ rightTarget.set(left.subarray(0, usable));
952
1184
  }
953
1185
  } else {
954
1186
  leftTarget.fill(0);
955
1187
  rightTarget.fill(0);
956
1188
  }
957
1189
  }
958
- this.realtime.process(frames);
959
- leftOut.set(this.realtime.outLeft);
960
- if (rightOut) {
961
- rightOut.set(this.realtime.outRight);
1190
+ this.realtime.process(usable);
1191
+ if (usable === frames) {
1192
+ leftOut.set(this.realtime.outLeft.subarray(0, usable));
1193
+ if (rightOut) {
1194
+ rightOut.set(this.realtime.outRight.subarray(0, usable));
1195
+ }
1196
+ } else {
1197
+ leftOut.fill(0);
1198
+ leftOut.set(this.realtime.outLeft.subarray(0, usable));
1199
+ if (rightOut) {
1200
+ rightOut.fill(0);
1201
+ rightOut.set(this.realtime.outRight.subarray(0, usable));
1202
+ }
962
1203
  }
963
- this.processedFrames += frames;
964
- this.publishMeter(this.realtime.outLeft, this.realtime.outRight);
965
- this.publishSpectrum(this.realtime.outLeft, this.realtime.outRight);
1204
+ this.processedFrames += usable;
1205
+ this.publishMeter(
1206
+ this.realtime.outLeft.subarray(0, usable),
1207
+ this.realtime.outRight.subarray(0, usable)
1208
+ );
1209
+ this.publishSpectrum(
1210
+ this.realtime.outLeft.subarray(0, usable),
1211
+ this.realtime.outRight.subarray(0, usable)
1212
+ );
966
1213
  return true;
967
1214
  }
968
1215
  receiveMessage(message) {
@@ -1048,16 +1295,14 @@ var SonareWorkletProcessor = class {
1048
1295
  }
1049
1296
  const writeIndex = Atomics.load(ring.header, 0);
1050
1297
  const offset = writeIndex % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
1051
- ring.records[offset] = meter.frame;
1298
+ ring.records[offset] = encodeFrameLo(meter.frame);
1052
1299
  ring.records[offset + 1] = meter.peakDbL;
1053
1300
  ring.records[offset + 2] = meter.peakDbR;
1054
1301
  ring.records[offset + 3] = meter.rmsDbL;
1055
1302
  ring.records[offset + 4] = meter.rmsDbR;
1056
1303
  ring.records[offset + 5] = meter.correlation;
1304
+ ring.records[offset + 6] = encodeFrameHi(meter.frame);
1057
1305
  Atomics.store(ring.header, 0, writeIndex + 1);
1058
- if (writeIndex + 1 > ring.capacity) {
1059
- Atomics.store(ring.header, 3, writeIndex + 1 - ring.capacity);
1060
- }
1061
1306
  }
1062
1307
  publishSpectrum(left, right) {
1063
1308
  if (this.spectrumIntervalFrames <= 0) {
@@ -1082,7 +1327,12 @@ var SonareWorkletProcessor = class {
1082
1327
  }
1083
1328
  computeSpectrum(left, right) {
1084
1329
  const n = Math.max(1, Math.min(left.length, right.length));
1330
+ const maxBand = Math.floor(n / 2);
1085
1331
  for (let band = 0; band < this.spectrumBands.length; band++) {
1332
+ if (band >= maxBand) {
1333
+ this.spectrumBands[band] = magnitudeToDb(0);
1334
+ continue;
1335
+ }
1086
1336
  const bin = band + 1;
1087
1337
  let real = 0;
1088
1338
  let imag = 0;
@@ -1102,19 +1352,20 @@ var SonareWorkletProcessor = class {
1102
1352
  }
1103
1353
  const writeIndex = Atomics.load(ring.header, 0);
1104
1354
  const offset = writeIndex % ring.capacity * ring.recordFloats;
1105
- ring.records[offset] = frame;
1106
- ring.records[offset + 1] = bands.length;
1107
- ring.records.set(bands.subarray(0, ring.bands), offset + 2);
1355
+ ring.records[offset] = encodeFrameLo(frame);
1356
+ ring.records[offset + 1] = encodeFrameHi(frame);
1357
+ ring.records[offset + 2] = bands.length;
1358
+ ring.records.set(bands.subarray(0, ring.bands), offset + 3);
1108
1359
  Atomics.store(ring.header, 0, writeIndex + 1);
1109
- if (writeIndex + 1 > ring.capacity) {
1110
- Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
1111
- }
1112
1360
  }
1113
1361
  };
1114
- var SonareRealtimeEngineWorkletProcessor = class {
1362
+ var _SonareRealtimeEngineWorkletProcessor = class _SonareRealtimeEngineWorkletProcessor {
1115
1363
  constructor(options = {}, transport) {
1116
1364
  this.closed = false;
1117
1365
  this.lastMeterFrame = Number.NEGATIVE_INFINITY;
1366
+ // Latest metronome gains/click length pushed via 'syncMetronome'. The
1367
+ // SetMetronome command only toggles enabled state; the config arrives here.
1368
+ this.metronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1118
1369
  this.sampleRate = options.sampleRate ?? 48e3;
1119
1370
  this.blockSize = options.blockSize ?? 128;
1120
1371
  this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
@@ -1131,7 +1382,13 @@ var SonareRealtimeEngineWorkletProcessor = class {
1131
1382
  options.telemetrySharedBuffer,
1132
1383
  options.telemetryRingCapacity
1133
1384
  ) : void 0;
1385
+ this.meterRing = options.meterSharedBuffer ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity) : void 0;
1134
1386
  this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
1387
+ this.engine.prepareChannels(this.channelCount, this.blockSize);
1388
+ this.channelBuffers = new Array(this.channelCount);
1389
+ for (let ch = 0; ch < this.channelCount; ch++) {
1390
+ this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1391
+ }
1135
1392
  }
1136
1393
  process(inputs, outputs) {
1137
1394
  if (this.closed) {
@@ -1151,22 +1408,38 @@ var SonareRealtimeEngineWorkletProcessor = class {
1151
1408
  return true;
1152
1409
  }
1153
1410
  this.drainCommands();
1154
- const channels = [];
1411
+ let usableFrames = frames;
1412
+ if (usableFrames > this.blockSize) {
1413
+ if (!_SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
1414
+ _SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
1415
+ console.warn(
1416
+ `SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames exceeds pre-allocated capacity ${this.blockSize}; clamping.`
1417
+ );
1418
+ }
1419
+ usableFrames = this.blockSize;
1420
+ }
1421
+ if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
1422
+ this.reacquireChannelBuffers();
1423
+ }
1155
1424
  const input = inputs[0];
1156
1425
  for (let ch = 0; ch < this.channelCount; ch++) {
1426
+ const dst = this.channelBuffers[ch];
1157
1427
  const source = input?.[ch];
1158
- const channel = new Float32Array(frames);
1159
- if (source && source.length === frames) {
1160
- channel.set(source);
1428
+ if (source && source.length === usableFrames) {
1429
+ dst.set(source.subarray(0, usableFrames));
1430
+ } else {
1431
+ dst.fill(0, 0, usableFrames);
1161
1432
  }
1162
- channels.push(channel);
1163
1433
  }
1164
- const processed = this.engine.process(channels);
1434
+ this.engine.processPrepared(usableFrames);
1165
1435
  for (let ch = 0; ch < output.length; ch++) {
1166
1436
  const target = output[ch];
1167
- const source = processed[ch] ?? processed[0];
1437
+ const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
1168
1438
  if (source) {
1169
- target.set(source.subarray(0, target.length));
1439
+ target.set(source.subarray(0, Math.min(target.length, usableFrames)));
1440
+ if (target.length > usableFrames) {
1441
+ target.fill(0, usableFrames);
1442
+ }
1170
1443
  } else {
1171
1444
  target.fill(0);
1172
1445
  }
@@ -1175,11 +1448,41 @@ var SonareRealtimeEngineWorkletProcessor = class {
1175
1448
  this.publishMeters();
1176
1449
  return true;
1177
1450
  }
1451
+ reacquireChannelBuffers() {
1452
+ for (let ch = 0; ch < this.channelCount; ch++) {
1453
+ this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1454
+ }
1455
+ }
1178
1456
  receiveCommand(command) {
1179
1457
  if (!this.closed) {
1180
1458
  this.applyCommand(command);
1181
1459
  }
1182
1460
  }
1461
+ // Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
1462
+ // global scope but OUTSIDE process() (the message-port callback), so the
1463
+ // bulk/allocating engine setters (setClips/setMarkers) are safe here — they
1464
+ // never run on the realtime render path. This is the audio-thread equivalent
1465
+ // of the engine's control-thread RtPublisher setters.
1466
+ receiveSync(message) {
1467
+ if (this.closed) {
1468
+ return;
1469
+ }
1470
+ switch (message.type) {
1471
+ case "syncClips":
1472
+ this.engine.setClips(message.clips);
1473
+ break;
1474
+ case "syncMarkers":
1475
+ this.engine.setMarkers(message.markers);
1476
+ break;
1477
+ case "syncMetronome":
1478
+ this.metronomeConfig = resolveMetronomeConfig(message.config);
1479
+ this.engine.setMetronome(message.config);
1480
+ break;
1481
+ case "syncAutomation":
1482
+ this.engine.setAutomationLane(message.paramId, message.points);
1483
+ break;
1484
+ }
1485
+ }
1183
1486
  destroy() {
1184
1487
  if (!this.closed) {
1185
1488
  this.engine.destroy();
@@ -1201,6 +1504,20 @@ var SonareRealtimeEngineWorkletProcessor = class {
1201
1504
  applyCommand(command) {
1202
1505
  const sampleTime = Number(command.sampleTime ?? -1);
1203
1506
  switch (command.type) {
1507
+ case 0 /* SetParam */:
1508
+ this.engine.setParameter(
1509
+ Math.trunc(Number(command.targetId ?? 0)),
1510
+ Number(command.argFloat ?? 0),
1511
+ sampleTime
1512
+ );
1513
+ break;
1514
+ case 1 /* SetParamSmoothed */:
1515
+ this.engine.setParameterSmoothed(
1516
+ Math.trunc(Number(command.targetId ?? 0)),
1517
+ Number(command.argFloat ?? 0),
1518
+ sampleTime
1519
+ );
1520
+ break;
1204
1521
  case 2 /* TransportPlay */:
1205
1522
  this.engine.play(sampleTime);
1206
1523
  break;
@@ -1229,18 +1546,21 @@ var SonareRealtimeEngineWorkletProcessor = class {
1229
1546
  case 14 /* Punch */:
1230
1547
  this.engine.setCapturePunch(
1231
1548
  Number(command.argInt ?? 0),
1232
- Math.max(0, Math.round(Number(command.argFloat ?? 0) * this.sampleRate)),
1549
+ Math.max(0, Math.round(Number(command.argFloat ?? 0))),
1233
1550
  true
1234
1551
  );
1235
1552
  break;
1236
1553
  case 15 /* SetMetronome */:
1237
1554
  this.engine.setMetronome({
1238
1555
  enabled: Boolean(command.argInt),
1239
- beatGain: 0.25,
1240
- accentGain: 0.75,
1241
- clickSamples: 64
1556
+ beatGain: this.metronomeConfig.beatGain,
1557
+ accentGain: this.metronomeConfig.accentGain,
1558
+ clickSamples: this.metronomeConfig.clickSamples
1242
1559
  });
1243
1560
  break;
1561
+ case 17 /* SeekMarker */:
1562
+ this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
1563
+ break;
1244
1564
  default:
1245
1565
  this.publishTelemetryRecord({
1246
1566
  type: 1 /* Error */,
@@ -1267,7 +1587,7 @@ var SonareRealtimeEngineWorkletProcessor = class {
1267
1587
  this.transport?.postMessage?.(record);
1268
1588
  }
1269
1589
  publishMeters() {
1270
- if (!this.transport || this.meterIntervalFrames <= 0) {
1590
+ if (this.meterIntervalFrames <= 0 || !this.transport && !this.meterRing) {
1271
1591
  return;
1272
1592
  }
1273
1593
  for (const item of this.engine.drainMeterTelemetry(64)) {
@@ -1276,10 +1596,30 @@ var SonareRealtimeEngineWorkletProcessor = class {
1276
1596
  continue;
1277
1597
  }
1278
1598
  this.lastMeterFrame = meter.frame;
1279
- this.transport.onMeter?.(meter);
1280
- this.transport.postMessage?.(meter);
1599
+ if (this.meterRing) {
1600
+ this.writeMeterRing(meter);
1601
+ } else {
1602
+ this.transport?.onMeter?.(meter);
1603
+ this.transport?.postMessage?.(meter);
1604
+ }
1281
1605
  }
1282
1606
  }
1607
+ writeMeterRing(meter) {
1608
+ const ring = this.meterRing;
1609
+ if (!ring) {
1610
+ return;
1611
+ }
1612
+ const writeIndex = Atomics.load(ring.header, 0);
1613
+ const offset = writeIndex % ring.capacity * SONARE_METER_RING_RECORD_FLOATS;
1614
+ ring.records[offset] = encodeFrameLo(meter.frame);
1615
+ ring.records[offset + 1] = meter.peakDbL;
1616
+ ring.records[offset + 2] = meter.peakDbR;
1617
+ ring.records[offset + 3] = meter.rmsDbL;
1618
+ ring.records[offset + 4] = meter.rmsDbR;
1619
+ ring.records[offset + 5] = meter.correlation;
1620
+ ring.records[offset + 6] = encodeFrameHi(meter.frame);
1621
+ Atomics.store(ring.header, 0, writeIndex + 1);
1622
+ }
1283
1623
  commandRingFromSharedBuffer(sharedBuffer, fallbackCapacity) {
1284
1624
  const ring = engineRingFromSharedBuffer(
1285
1625
  sharedBuffer,
@@ -1297,8 +1637,11 @@ var SonareRealtimeEngineWorkletProcessor = class {
1297
1637
  return { sharedBuffer, header: ring.header, view: ring.view, capacity: ring.capacity };
1298
1638
  }
1299
1639
  };
1640
+ _SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = false;
1641
+ var SonareRealtimeEngineWorkletProcessor = _SonareRealtimeEngineWorkletProcessor;
1300
1642
  var SonareRtRealtimeEngineRuntime = class {
1301
1643
  constructor(options) {
1644
+ this.metronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1302
1645
  this.closed = false;
1303
1646
  this.module = options.module;
1304
1647
  this.memory = options.memory;
@@ -1380,6 +1723,49 @@ var SonareRtRealtimeEngineRuntime = class {
1380
1723
  this.publishTelemetry();
1381
1724
  return true;
1382
1725
  }
1726
+ receiveCommand(command) {
1727
+ if (!this.closed) {
1728
+ this.applyCommand(command);
1729
+ }
1730
+ }
1731
+ // Out-of-band control sync for the sonare-rt runtime. The sonare-rt C ABI
1732
+ // (src/wasm/rt_bindings.cpp) exposes set_metronome_enabled and seek_marker but
1733
+ // NOT set_clips / set_markers, so clip/marker mutations cannot be applied to a
1734
+ // live sonare-rt engine. We honor the metronome config and surface a clear
1735
+ // telemetry error for the unsupported clip/marker paths instead of silently
1736
+ // dropping them. The default 'embind' runtime wires all three fully.
1737
+ receiveSync(message) {
1738
+ if (this.closed) {
1739
+ return;
1740
+ }
1741
+ switch (message.type) {
1742
+ case "syncMetronome":
1743
+ this.metronomeConfig = resolveMetronomeConfig(message.config);
1744
+ this.module._sonare_rt_engine_set_metronome_enabled(
1745
+ this.engine,
1746
+ message.config.enabled ? 1 : 0,
1747
+ this.metronomeConfig.beatGain,
1748
+ this.metronomeConfig.accentGain,
1749
+ this.metronomeConfig.clickSamples
1750
+ );
1751
+ break;
1752
+ case "syncClips":
1753
+ case "syncMarkers":
1754
+ case "syncAutomation":
1755
+ if (this.telemetryRing) {
1756
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1757
+ type: 1 /* Error */,
1758
+ error: 7 /* UnknownTarget */,
1759
+ renderFrame: 0,
1760
+ timelineSample: 0,
1761
+ audibleTimelineSample: 0,
1762
+ graphLatencySamplesQ8: 0,
1763
+ value: 0
1764
+ });
1765
+ }
1766
+ break;
1767
+ }
1768
+ }
1383
1769
  destroy() {
1384
1770
  if (this.closed) {
1385
1771
  return;
@@ -1415,6 +1801,20 @@ var SonareRtRealtimeEngineRuntime = class {
1415
1801
  applyCommand(command) {
1416
1802
  const sampleTime = toBigInt64(command.sampleTime, -1n);
1417
1803
  switch (command.type) {
1804
+ case 0 /* SetParam */:
1805
+ case 1 /* SetParamSmoothed */:
1806
+ if (this.telemetryRing) {
1807
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1808
+ type: 1 /* Error */,
1809
+ error: 7 /* UnknownTarget */,
1810
+ renderFrame: 0,
1811
+ timelineSample: 0,
1812
+ audibleTimelineSample: 0,
1813
+ graphLatencySamplesQ8: 0,
1814
+ value: Number(command.type)
1815
+ });
1816
+ }
1817
+ break;
1418
1818
  case 2 /* TransportPlay */:
1419
1819
  this.module._sonare_rt_engine_play(this.engine, sampleTime);
1420
1820
  break;
@@ -1453,7 +1853,7 @@ var SonareRtRealtimeEngineRuntime = class {
1453
1853
  this.module._sonare_rt_engine_set_capture_punch(
1454
1854
  this.engine,
1455
1855
  toBigInt64(command.argInt, 0n),
1456
- BigInt(Math.trunc(Number(command.argFloat ?? 0) * this.sampleRate)),
1856
+ BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
1457
1857
  1
1458
1858
  );
1459
1859
  break;
@@ -1461,9 +1861,9 @@ var SonareRtRealtimeEngineRuntime = class {
1461
1861
  this.module._sonare_rt_engine_set_metronome_enabled(
1462
1862
  this.engine,
1463
1863
  command.argInt ? 1 : 0,
1464
- 0.25,
1465
- 0.75,
1466
- 64
1864
+ this.metronomeConfig.beatGain,
1865
+ this.metronomeConfig.accentGain,
1866
+ this.metronomeConfig.clickSamples
1467
1867
  );
1468
1868
  break;
1469
1869
  case 17 /* SeekMarker */:
@@ -1538,8 +1938,9 @@ var SonareRtRealtimeEngineRuntime = class {
1538
1938
  }
1539
1939
  };
1540
1940
  var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1541
- constructor(node, capabilities, commandRing, telemetryRing) {
1941
+ constructor(node, capabilities, commandRing, telemetryRing, meterRing) {
1542
1942
  this.telemetryReadIndex = 0;
1943
+ this.meterReadIndex = 0;
1543
1944
  this.telemetryListeners = /* @__PURE__ */ new Set();
1544
1945
  this.meterListeners = /* @__PURE__ */ new Set();
1545
1946
  this.destroyed = false;
@@ -1547,6 +1948,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1547
1948
  this.capabilities = capabilities;
1548
1949
  this.commandRing = commandRing;
1549
1950
  this.telemetryRing = telemetryRing;
1951
+ this.meterRing = meterRing;
1550
1952
  this.ready = new Promise((resolve, reject) => {
1551
1953
  this.resolveReady = resolve;
1552
1954
  this.rejectReady = reject;
@@ -1595,6 +1997,7 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1595
1997
  }
1596
1998
  const commandRing = mode === "sab" ? createSonareEngineCommandRingBuffer(options.commandRingCapacity ?? 128) : void 0;
1597
1999
  const telemetryRing = mode === "sab" ? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128) : void 0;
2000
+ const meterRing = mode === "sab" && runtimeTarget === "embind" ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128) : void 0;
1598
2001
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1599
2002
  const processorOptions = {
1600
2003
  runtimeTarget,
@@ -1606,7 +2009,9 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1606
2009
  commandSharedBuffer: commandRing?.sharedBuffer,
1607
2010
  commandRingCapacity: commandRing?.capacity,
1608
2011
  telemetrySharedBuffer: telemetryRing?.sharedBuffer,
1609
- telemetryRingCapacity: telemetryRing?.capacity
2012
+ telemetryRingCapacity: telemetryRing?.capacity,
2013
+ meterSharedBuffer: meterRing?.sharedBuffer,
2014
+ meterRingCapacity: meterRing?.capacity
1610
2015
  };
1611
2016
  const factory = options.nodeFactory ?? ((ctx, name, nodeOptions) => new AudioWorkletNode(ctx, name, nodeOptions));
1612
2017
  const node = factory(context, processorName, {
@@ -1629,7 +2034,8 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1629
2034
  degradedReason
1630
2035
  },
1631
2036
  commandRing,
1632
- telemetryRing
2037
+ telemetryRing,
2038
+ meterRing
1633
2039
  );
1634
2040
  }
1635
2041
  play(sampleTime = -1) {
@@ -1673,6 +2079,20 @@ var SonareRealtimeEngineNode = class _SonareRealtimeEngineNode {
1673
2079
  }
1674
2080
  return read.telemetry;
1675
2081
  }
2082
+ // Drains any meters published into the SAB meter ring (embind SAB mode) and
2083
+ // forwards them to onMeter listeners. In postMessage mode meters arrive via
2084
+ // node.port.onmessage instead, so this is a no-op then.
2085
+ pollMeters() {
2086
+ if (!this.meterRing) {
2087
+ return [];
2088
+ }
2089
+ const read = readSonareMeterRingBuffer(this.meterRing, this.meterReadIndex);
2090
+ this.meterReadIndex = read.nextReadIndex;
2091
+ for (const meter of read.meters) {
2092
+ this.emitMeter(meter);
2093
+ }
2094
+ return read.meters;
2095
+ }
1676
2096
  onTelemetry(callback) {
1677
2097
  this.telemetryListeners.add(callback);
1678
2098
  return () => {
@@ -1787,10 +2207,14 @@ var SonareEngine = class _SonareEngine {
1787
2207
  });
1788
2208
  }
1789
2209
  setParam(nodeId, param, value) {
1790
- void nodeId;
1791
- void param;
1792
- void value;
1793
- return false;
2210
+ const paramId = this.resolveParamId(nodeId, param);
2211
+ this.offlineEngine.setParameter(paramId, value);
2212
+ return this.realtimeNode.sendCommand({
2213
+ type: 0 /* SetParam */,
2214
+ targetId: paramId,
2215
+ sampleTime: -1,
2216
+ argFloat: value
2217
+ });
1794
2218
  }
1795
2219
  scheduleParam(nodeId, param, ppq, value, curve = "linear") {
1796
2220
  const paramId = this.resolveParamId(nodeId, param);
@@ -1799,6 +2223,7 @@ var SonareEngine = class _SonareEngine {
1799
2223
  lane.sort((a, b) => a.ppq - b.ppq);
1800
2224
  this.automationLanes.set(paramId, lane);
1801
2225
  this.offlineEngine.setAutomationLane(paramId, lane);
2226
+ this.postSync({ type: "syncAutomation", paramId, points: lane });
1802
2227
  }
1803
2228
  addAutomationPoint(laneId, ppq, value, curve = "linear") {
1804
2229
  this.scheduleParam("", laneId, ppq, value, curve);
@@ -1814,7 +2239,9 @@ var SonareEngine = class _SonareEngine {
1814
2239
  void target;
1815
2240
  void solo;
1816
2241
  void mute;
1817
- return false;
2242
+ throw new Error(
2243
+ "SonareEngine.setSoloMute is not supported: solo/mute is a Mixer feature; use Mixer.setSoloed(stripIndex, ...) / Mixer.setMuted(stripIndex, ...) instead."
2244
+ );
1818
2245
  }
1819
2246
  addClip(trackId, buffer, startPpq, opts = {}) {
1820
2247
  const id = opts.id ?? this.nextClipId++;
@@ -1850,11 +2277,12 @@ var SonareEngine = class _SonareEngine {
1850
2277
  type: 14 /* Punch */,
1851
2278
  sampleTime: -1,
1852
2279
  argInt: inSample,
1853
- argFloat: outPpq
2280
+ argFloat: outSample
1854
2281
  });
1855
2282
  }
1856
2283
  setMetronome(opts) {
1857
2284
  this.offlineEngine.setMetronome(opts);
2285
+ this.postSync({ type: "syncMetronome", config: opts });
1858
2286
  this.realtimeNode.sendCommand({
1859
2287
  type: 15 /* SetMetronome */,
1860
2288
  sampleTime: -1,
@@ -1869,7 +2297,11 @@ var SonareEngine = class _SonareEngine {
1869
2297
  }
1870
2298
  seekMarker(markerId) {
1871
2299
  this.offlineEngine.seekMarker(markerId);
1872
- return false;
2300
+ return this.realtimeNode.sendCommand({
2301
+ type: 17 /* SeekMarker */,
2302
+ targetId: markerId,
2303
+ sampleTime: -1
2304
+ });
1873
2305
  }
1874
2306
  async renderOffline(totalFrames) {
1875
2307
  const frames = Math.max(0, Math.floor(totalFrames));
@@ -1888,6 +2320,9 @@ var SonareEngine = class _SonareEngine {
1888
2320
  pollTelemetry() {
1889
2321
  return this.realtimeNode.pollTelemetry();
1890
2322
  }
2323
+ pollMeters() {
2324
+ return this.realtimeNode.pollMeters();
2325
+ }
1891
2326
  destroy() {
1892
2327
  if (this.destroyed) {
1893
2328
  return;
@@ -1899,10 +2334,23 @@ var SonareEngine = class _SonareEngine {
1899
2334
  this.offlineEngine.destroy();
1900
2335
  }
1901
2336
  syncClips() {
1902
- this.offlineEngine.setClips(Array.from(this.clips.values()));
2337
+ const clips = Array.from(this.clips.values());
2338
+ this.offlineEngine.setClips(clips);
2339
+ this.postSync({ type: "syncClips", clips });
1903
2340
  }
1904
2341
  syncMarkers() {
1905
- this.offlineEngine.setMarkers(Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq));
2342
+ const markers = Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq);
2343
+ this.offlineEngine.setMarkers(markers);
2344
+ this.postSync({ type: "syncMarkers", markers });
2345
+ }
2346
+ // Posts an out-of-band control-sync message to the worklet engine processor.
2347
+ // Sync messages use a string `type` so the worklet's message handler routes
2348
+ // them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
2349
+ postSync(message) {
2350
+ if (this.destroyed) {
2351
+ return;
2352
+ }
2353
+ this.realtimeNode.node.port.postMessage(message);
1906
2354
  }
1907
2355
  resolveParamId(nodeId, param) {
1908
2356
  if (typeof param === "number") {
@@ -1931,6 +2379,156 @@ var SonareEngine = class _SonareEngine {
1931
2379
  return Math.max(0, Math.round(ppq * 60 / 120 * this.sampleRate));
1932
2380
  }
1933
2381
  };
2382
+ var _SonareRealtimeVoiceChangerWorkletProcessor = class _SonareRealtimeVoiceChangerWorkletProcessor {
2383
+ constructor(options = {}) {
2384
+ this.destroyed = false;
2385
+ this.sampleRate = options.sampleRate ?? 48e3;
2386
+ this.blockSize = options.blockSize ?? 128;
2387
+ this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 1));
2388
+ this.changer = new RealtimeVoiceChanger(options.preset ?? "neutral-monitor");
2389
+ this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
2390
+ this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
2391
+ this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
2392
+ this.planarChannels = [];
2393
+ if (this.channelCount > 1) {
2394
+ for (let ch = 0; ch < this.channelCount; ch++) {
2395
+ this.planarChannels.push(this.changer.getPlanarChannelBuffer(ch, this.blockSize));
2396
+ }
2397
+ }
2398
+ }
2399
+ /**
2400
+ * Handles a control-plane message from the main thread. Runs on the
2401
+ * AudioWorklet global scope but OUTSIDE of `process()` (i.e. outside the
2402
+ * realtime audio callback), so it is safe to perform JSON parsing and
2403
+ * DSP coefficient recomputation here. `setConfig` MUST NOT be deferred
2404
+ * into `process()` because that would block the audio thread for longer
2405
+ * than one render quantum (e.g. 128 samples / 44.1 kHz = ~2.9 ms).
2406
+ */
2407
+ receiveMessage(message) {
2408
+ if (this.destroyed) {
2409
+ return;
2410
+ }
2411
+ if (message.type === "setConfig") {
2412
+ this.changer.setConfig(message.preset);
2413
+ } else if (message.type === "reset") {
2414
+ this.changer.reset();
2415
+ } else if (message.type === "destroy") {
2416
+ this.destroy();
2417
+ }
2418
+ }
2419
+ process(inputs, outputs) {
2420
+ const output = outputs[0];
2421
+ if (this.destroyed || !output || output.length === 0) {
2422
+ return !this.destroyed;
2423
+ }
2424
+ if (this.monoInput.byteLength === 0) {
2425
+ this.reacquireBuffers();
2426
+ }
2427
+ const input = inputs[0];
2428
+ const requestedFrames = output[0]?.length ?? 0;
2429
+ const requestedChannels = Math.min(this.channelCount, output.length);
2430
+ if (requestedFrames === 0 || requestedChannels === 0) {
2431
+ return true;
2432
+ }
2433
+ if (requestedChannels === 1) {
2434
+ const frames2 = this.ensureMonoCapacity(requestedFrames);
2435
+ const source = input?.[0];
2436
+ if (source) {
2437
+ this.monoInput.set(source.subarray(0, frames2));
2438
+ } else {
2439
+ this.monoInput.fill(0, 0, frames2);
2440
+ }
2441
+ this.changer.processMonoInto(
2442
+ this.monoInput.subarray(0, frames2),
2443
+ this.monoOutput.subarray(0, frames2)
2444
+ );
2445
+ output[0].set(this.monoOutput.subarray(0, frames2));
2446
+ return true;
2447
+ }
2448
+ const frames = this.ensureInterleavedCapacity(requestedFrames, requestedChannels);
2449
+ const channels = requestedChannels;
2450
+ for (let ch = 0; ch < channels; ch++) {
2451
+ const src = input?.[ch];
2452
+ const dst = this.planarChannels[ch];
2453
+ if (!dst) {
2454
+ continue;
2455
+ }
2456
+ if (src) {
2457
+ dst.set(src.subarray(0, frames));
2458
+ } else {
2459
+ dst.fill(0, 0, frames);
2460
+ }
2461
+ }
2462
+ this.changer.processPreparedPlanar(frames);
2463
+ for (let ch = 0; ch < channels; ch++) {
2464
+ const src = this.planarChannels[ch];
2465
+ if (src) {
2466
+ output[ch].set(src.subarray(0, frames));
2467
+ }
2468
+ }
2469
+ return true;
2470
+ }
2471
+ destroy() {
2472
+ if (this.destroyed) {
2473
+ return;
2474
+ }
2475
+ this.destroyed = true;
2476
+ this.changer.delete();
2477
+ }
2478
+ // Re-acquires the cached WASM-heap views after a memory-growth detachment.
2479
+ // The underlying C++ vectors are pre-warmed (ensure_*_capacity ran at prepare
2480
+ // time), so getMono*/getPlanar* return fresh views onto the SAME storage
2481
+ // without reallocating it.
2482
+ reacquireBuffers() {
2483
+ this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
2484
+ this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
2485
+ if (this.channelCount > 1) {
2486
+ for (let ch = 0; ch < this.channelCount; ch++) {
2487
+ this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
2488
+ }
2489
+ }
2490
+ }
2491
+ /**
2492
+ * Returns the number of frames we can actually process given the
2493
+ * pre-allocated capacity. If the host requests more frames than the
2494
+ * worst-case block size declared at construction time, we clamp to the
2495
+ * available capacity and warn once — we MUST NOT reallocate on the
2496
+ * realtime audio thread.
2497
+ */
2498
+ ensureMonoCapacity(frames) {
2499
+ const capacity = this.monoInput.length;
2500
+ if (frames <= capacity) {
2501
+ return frames;
2502
+ }
2503
+ if (!_SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow) {
2504
+ _SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow = true;
2505
+ console.warn(
2506
+ `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames} mono frames exceeds pre-allocated capacity ${capacity}; clamping. Increase blockSize at construction time to avoid this.`
2507
+ );
2508
+ }
2509
+ return capacity;
2510
+ }
2511
+ /**
2512
+ * Same contract as ensureMonoCapacity but for the planar per-channel
2513
+ * scratch. Returns the number of frames that fit in the available capacity.
2514
+ */
2515
+ ensureInterleavedCapacity(frames, channels) {
2516
+ const capacity = this.planarChannels[0]?.length ?? 0;
2517
+ if (frames <= capacity) {
2518
+ return frames;
2519
+ }
2520
+ if (!_SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow) {
2521
+ _SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow = true;
2522
+ console.warn(
2523
+ `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames}x${channels} planar frames exceeds pre-allocated capacity ${capacity}; clamping. Increase blockSize or channelCount at construction time to avoid this.`
2524
+ );
2525
+ }
2526
+ return capacity;
2527
+ }
2528
+ };
2529
+ _SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow = false;
2530
+ _SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow = false;
2531
+ var SonareRealtimeVoiceChangerWorkletProcessor = _SonareRealtimeVoiceChangerWorkletProcessor;
1934
2532
  function registerSonareWorkletProcessor(name = "sonare-worklet-processor") {
1935
2533
  const scope = globalThis;
1936
2534
  if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
@@ -1962,6 +2560,35 @@ function registerSonareWorkletProcessor(name = "sonare-worklet-processor") {
1962
2560
  }
1963
2561
  scope.registerProcessor(name, RegisteredSonareWorkletProcessor);
1964
2562
  }
2563
+ function registerSonareRealtimeVoiceChangerWorkletProcessor(name = "sonare-realtime-voice-changer-processor") {
2564
+ const scope = globalThis;
2565
+ if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
2566
+ throw new Error("AudioWorkletProcessor is not available in this context.");
2567
+ }
2568
+ const Base = scope.AudioWorkletProcessor;
2569
+ class RegisteredSonareRealtimeVoiceChangerWorkletProcessor extends Base {
2570
+ constructor(options) {
2571
+ super();
2572
+ const port = this.port;
2573
+ this.bridge = new SonareRealtimeVoiceChangerWorkletProcessor(options?.processorOptions ?? {});
2574
+ const onMessage = (event) => {
2575
+ if (isRealtimeVoiceChangerMessage(event.data)) {
2576
+ this.bridge.receiveMessage(event.data);
2577
+ }
2578
+ };
2579
+ if (port?.addEventListener) {
2580
+ port.addEventListener("message", onMessage);
2581
+ port.start?.();
2582
+ } else if (port) {
2583
+ port.onmessage = onMessage;
2584
+ }
2585
+ }
2586
+ process(inputs, outputs) {
2587
+ return this.bridge.process(inputs, outputs);
2588
+ }
2589
+ }
2590
+ scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
2591
+ }
1965
2592
  function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-engine-processor") {
1966
2593
  const scope = globalThis;
1967
2594
  if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
@@ -1984,6 +2611,10 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
1984
2611
  const onMessage = (event) => {
1985
2612
  if (isEngineCommandRecord(event.data)) {
1986
2613
  this.bridge?.receiveCommand(event.data);
2614
+ this.rtBridge?.receiveCommand(event.data);
2615
+ } else if (isEngineSyncMessage(event.data)) {
2616
+ this.bridge?.receiveSync(event.data);
2617
+ this.rtBridge?.receiveSync(event.data);
1987
2618
  }
1988
2619
  };
1989
2620
  if (port?.addEventListener) {
@@ -2011,13 +2642,14 @@ function registerSonareRealtimeEngineWorkletProcessor(name = "sonare-realtime-en
2011
2642
  if (!options.rtModuleUrl) {
2012
2643
  throw new Error("rtModuleUrl is required for sonare-rt AudioWorklet runtime.");
2013
2644
  }
2645
+ const rtModuleUrl = options.rtModuleUrl;
2014
2646
  const memory = new WebAssembly.Memory({ initial: 1024, maximum: 1024, shared: true });
2015
2647
  const globalFactory = globalThis.SonareRtModuleFactory;
2016
- const moduleFactory = globalFactory ? { default: globalFactory } : await import(options.rtModuleUrl);
2648
+ const moduleFactory = globalFactory ? { default: globalFactory } : await import(rtModuleUrl);
2017
2649
  const module2 = await moduleFactory.default({
2018
2650
  wasmMemory: memory,
2019
2651
  wasmBinary: options.rtWasmBinary,
2020
- locateFile: (path) => options.rtModuleUrl.replace(/[^/]*$/, path)
2652
+ locateFile: (path) => rtModuleUrl.replace(/[^/]*$/, path)
2021
2653
  });
2022
2654
  this.rtBridge = new SonareRtRealtimeEngineRuntime({
2023
2655
  module: module2,
@@ -2054,12 +2686,16 @@ export {
2054
2686
  SonareEngineTelemetryType,
2055
2687
  SonareRealtimeEngineNode,
2056
2688
  SonareRealtimeEngineWorkletProcessor,
2689
+ SonareRealtimeVoiceChangerWorkletProcessor,
2057
2690
  SonareRtRealtimeEngineRuntime,
2058
2691
  SonareWorkletProcessor,
2059
2692
  createSonareEngineCommandRingBuffer,
2060
2693
  createSonareEngineTelemetryRingBuffer,
2061
2694
  createSonareMeterRingBuffer,
2062
2695
  createSonareSpectrumRingBuffer,
2696
+ decodeFrame,
2697
+ encodeFrameHi,
2698
+ encodeFrameLo,
2063
2699
  init,
2064
2700
  isInitialized,
2065
2701
  popSonareEngineCommandRingBuffer,
@@ -2068,6 +2704,7 @@ export {
2068
2704
  readSonareMeterRingBuffer,
2069
2705
  readSonareSpectrumRingBuffer,
2070
2706
  registerSonareRealtimeEngineWorkletProcessor,
2707
+ registerSonareRealtimeVoiceChangerWorkletProcessor,
2071
2708
  registerSonareWorkletProcessor,
2072
2709
  sonareEngineCommandRingBufferByteLength,
2073
2710
  sonareEngineTelemetryRingBufferByteLength,