@libraz/libsonare 1.2.2 → 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/src/worklet.ts CHANGED
@@ -46,6 +46,8 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
46
46
  commandRingCapacity?: number;
47
47
  telemetrySharedBuffer?: SharedArrayBuffer;
48
48
  telemetryRingCapacity?: number;
49
+ meterSharedBuffer?: SharedArrayBuffer;
50
+ meterRingCapacity?: number;
49
51
  }
50
52
 
51
53
  export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
@@ -182,8 +184,33 @@ export type SonareWorkletTransportMessage =
182
184
  | SonareEngineTelemetryRecord;
183
185
 
184
186
  export const SONARE_METER_RING_HEADER_INTS = 4;
185
- export const SONARE_METER_RING_RECORD_FLOATS = 6;
187
+ // Record layout: [frameLo, peakDbL, peakDbR, rmsDbL, rmsDbR, correlation, frameHi].
188
+ // The sample-frame index is monotonically increasing and quickly exceeds the
189
+ // 2^24 exact-integer range of a single Float32 slot (~349 s at 48 kHz), so it is
190
+ // stored split across two Float32 lanes (low 24 bits + high bits) for exact
191
+ // reconstruction. See encodeFrameLo/encodeFrameHi/decodeFrame.
192
+ export const SONARE_METER_RING_RECORD_FLOATS = 7;
186
193
  export const SONARE_SPECTRUM_RING_HEADER_INTS = 5;
194
+
195
+ /** Base for splitting a frame index into two exactly-representable Float32 lanes. */
196
+ const SONARE_FRAME_LANE_BASE = 0x1000000; // 2^24
197
+
198
+ /** Low 24 bits of a frame index (exact in Float32). */
199
+ export function encodeFrameLo(frame: number): number {
200
+ const f = Math.max(0, Math.floor(frame));
201
+ return f % SONARE_FRAME_LANE_BASE;
202
+ }
203
+
204
+ /** High bits of a frame index above 2^24 (exact in Float32 up to ~2^48). */
205
+ export function encodeFrameHi(frame: number): number {
206
+ const f = Math.max(0, Math.floor(frame));
207
+ return Math.floor(f / SONARE_FRAME_LANE_BASE);
208
+ }
209
+
210
+ /** Reconstruct a frame index from its low/high Float32 lanes. */
211
+ export function decodeFrame(lo: number, hi: number): number {
212
+ return hi * SONARE_FRAME_LANE_BASE + lo;
213
+ }
187
214
  export const SONARE_ENGINE_RING_HEADER_INTS = 5;
188
215
  export const SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
189
216
  export const SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
@@ -237,6 +264,29 @@ interface WorkletTransport {
237
264
  onSpectrum?: (spectrum: SonareWorkletSpectrumSnapshot) => void;
238
265
  }
239
266
 
267
+ interface ResolvedMetronomeConfig {
268
+ beatGain: number;
269
+ accentGain: number;
270
+ clickSamples: number;
271
+ }
272
+
273
+ // Fallback metronome gains/click length used by the worklet consumer until the
274
+ // host posts a 'syncMetronome' config. Aligned with the embind setMetronome
275
+ // defaults (src/wasm/bindings.cpp) so offline and realtime metronomes match.
276
+ const DEFAULT_METRONOME_CONFIG: ResolvedMetronomeConfig = {
277
+ beatGain: 0.35,
278
+ accentGain: 0.7,
279
+ clickSamples: 96,
280
+ };
281
+
282
+ function resolveMetronomeConfig(config: EngineMetronomeConfig): ResolvedMetronomeConfig {
283
+ return {
284
+ beatGain: config.beatGain ?? DEFAULT_METRONOME_CONFIG.beatGain,
285
+ accentGain: config.accentGain ?? DEFAULT_METRONOME_CONFIG.accentGain,
286
+ clickSamples: config.clickSamples ?? DEFAULT_METRONOME_CONFIG.clickSamples,
287
+ };
288
+ }
289
+
240
290
  export interface SonareMeterRingBuffer {
241
291
  sharedBuffer: SharedArrayBuffer;
242
292
  header: Int32Array;
@@ -270,6 +320,39 @@ export interface SonareEngineCommandRecord {
270
320
  argInt?: number | bigint;
271
321
  }
272
322
 
323
+ // Out-of-band control messages posted from the main-thread SonareEngine facade
324
+ // to the worklet engine processor over node.port. Unlike SonareEngineCommandRecord
325
+ // (a small POD POSTed/ringed every block) these carry bulk/structured payloads
326
+ // (clip audio buffers, marker lists, metronome config) that cannot fit the
327
+ // fixed-size SAB command record, so they are applied OUTSIDE process() — the
328
+ // audio-thread equivalent of the engine's control-thread RtPublisher setters.
329
+ export interface SonareEngineSyncClipsMessage {
330
+ type: 'syncClips';
331
+ clips: EngineClip[];
332
+ }
333
+
334
+ export interface SonareEngineSyncMarkersMessage {
335
+ type: 'syncMarkers';
336
+ markers: EngineMarker[];
337
+ }
338
+
339
+ export interface SonareEngineSyncMetronomeMessage {
340
+ type: 'syncMetronome';
341
+ config: EngineMetronomeConfig;
342
+ }
343
+
344
+ export interface SonareEngineSyncAutomationMessage {
345
+ type: 'syncAutomation';
346
+ paramId: number;
347
+ points: EngineAutomationPoint[];
348
+ }
349
+
350
+ export type SonareEngineSyncMessage =
351
+ | SonareEngineSyncClipsMessage
352
+ | SonareEngineSyncMarkersMessage
353
+ | SonareEngineSyncMetronomeMessage
354
+ | SonareEngineSyncAutomationMessage;
355
+
273
356
  export interface SonareEngineTelemetryRecord {
274
357
  type: SonareEngineTelemetryType | number;
275
358
  error: SonareEngineTelemetryError | number;
@@ -343,6 +426,18 @@ function isEngineCommandRecord(value: unknown): value is SonareEngineCommandReco
343
426
  return isRecord(value) && typeof value.type === 'number';
344
427
  }
345
428
 
429
+ function isEngineSyncMessage(value: unknown): value is SonareEngineSyncMessage {
430
+ if (!isRecord(value) || typeof value.type !== 'string') {
431
+ return false;
432
+ }
433
+ return (
434
+ value.type === 'syncClips' ||
435
+ value.type === 'syncMarkers' ||
436
+ value.type === 'syncMetronome' ||
437
+ value.type === 'syncAutomation'
438
+ );
439
+ }
440
+
346
441
  function isRealtimeVoiceChangerMessage(value: unknown): value is SonareRealtimeVoiceChangerMessage {
347
442
  if (!isRecord(value) || typeof value.type !== 'string') {
348
443
  return false;
@@ -407,7 +502,7 @@ export function readSonareMeterRingBuffer(
407
502
  const offset = (index % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
408
503
  meters.push({
409
504
  type: 'meter',
410
- frame: ring.records[offset],
505
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
411
506
  peakDbL: ring.records[offset + 1],
412
507
  peakDbR: ring.records[offset + 2],
413
508
  rmsDbL: ring.records[offset + 3],
@@ -421,9 +516,11 @@ export function readSonareMeterRingBuffer(
421
516
  export function sonareSpectrumRingBufferByteLength(capacity: number, bands = 16): number {
422
517
  const clampedCapacity = Math.max(1, Math.floor(capacity));
423
518
  const clampedBands = Math.max(1, Math.floor(bands));
519
+ // Record layout: [frameLo, frameHi, bandCount, band0, band1, ...]. frame is
520
+ // split across two Float32 lanes for exact reconstruction beyond 2^24.
424
521
  return (
425
522
  SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
426
- clampedCapacity * (2 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
523
+ clampedCapacity * (3 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
427
524
  );
428
525
  }
429
526
 
@@ -456,7 +553,7 @@ export function readSonareSpectrumRingBuffer(
456
553
  readIndex = 0,
457
554
  ): SonareSpectrumRingReadResult {
458
555
  const writeIndex = Atomics.load(ring.header, 0);
459
- const recordFloats = Atomics.load(ring.header, 2) || 2 + ring.bands;
556
+ const recordFloats = Atomics.load(ring.header, 2) || 3 + ring.bands;
460
557
  const bands = Atomics.load(ring.header, 3) || ring.bands;
461
558
  const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
462
559
  const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
@@ -464,8 +561,12 @@ export function readSonareSpectrumRingBuffer(
464
561
  for (let index = firstReadable; index < writeIndex; index++) {
465
562
  const offset = (index % ring.capacity) * recordFloats;
466
563
  const values = new Float32Array(bands);
467
- values.set(ring.records.subarray(offset + 2, offset + 2 + bands));
468
- spectra.push({ type: 'spectrum', frame: ring.records[offset], bands: values });
564
+ values.set(ring.records.subarray(offset + 3, offset + 3 + bands));
565
+ spectra.push({
566
+ type: 'spectrum',
567
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 1]),
568
+ bands: values,
569
+ });
469
570
  }
470
571
  return { nextReadIndex: writeIndex, spectra };
471
572
  }
@@ -620,7 +721,7 @@ function spectrumRingFromSharedBuffer(
620
721
  const existingBands = Atomics.load(header, 3);
621
722
  const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
622
723
  const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
623
- const recordFloats = 2 + bands;
724
+ const recordFloats = 3 + bands;
624
725
  const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
625
726
  if (sharedBuffer.byteLength < minBytes) {
626
727
  throw new Error('spectrumSharedBuffer is too small for the requested ring capacity.');
@@ -681,8 +782,10 @@ function writeEngineCommandRecord(
681
782
  view.setUint32(offset, command.type, true);
682
783
  view.setUint32(offset + 4, command.targetId ?? 0, true);
683
784
  view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
684
- view.setFloat32(offset + 16, command.argFloat ?? 0, true);
685
- view.setUint32(offset + 20, 0, true);
785
+ // argFloat occupies a full 8-byte Float64 slot (replacing the old Float32 +
786
+ // 4-byte pad) so PPQ scalars carried here keep full double precision over the
787
+ // SAB transport, matching the engine's double-precision seek/loop contract.
788
+ view.setFloat64(offset + 16, command.argFloat ?? 0, true);
686
789
  view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
687
790
  }
688
791
 
@@ -691,7 +794,7 @@ function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCo
691
794
  type: view.getUint32(offset, true),
692
795
  targetId: view.getUint32(offset + 4, true),
693
796
  sampleTime: Number(view.getBigInt64(offset + 8, true)),
694
- argFloat: view.getFloat32(offset + 16, true),
797
+ argFloat: view.getFloat64(offset + 16, true),
695
798
  argInt: Number(view.getBigInt64(offset + 24, true)),
696
799
  };
697
800
  }
@@ -818,9 +921,14 @@ export class SonareWorkletProcessor {
818
921
  return true;
819
922
  }
820
923
  const frames = leftOut.length;
821
- if (frames !== this.blockSize) {
822
- return false;
823
- }
924
+ // The mixer's realtime heap buffers are sized to blockSize. A render quantum
925
+ // that differs from blockSize (e.g. a future browser using a quantum other
926
+ // than 128, or a misconfigured blockSize) must NOT return false here:
927
+ // returning false permanently terminates the AudioWorkletProcessor and
928
+ // silently kills the node mid-stream. Instead degrade gracefully by
929
+ // processing min(frames, blockSize) and zero-filling any remainder, mirroring
930
+ // the sonare-rt processor's behaviour.
931
+ const usable = Math.min(frames, this.blockSize);
824
932
 
825
933
  for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
826
934
  const input = inputs[strip];
@@ -828,12 +936,12 @@ export class SonareWorkletProcessor {
828
936
  const right = input?.[1];
829
937
  const leftTarget = this.realtime.leftInputs[strip];
830
938
  const rightTarget = this.realtime.rightInputs[strip];
831
- if (left && left.length === frames) {
832
- leftTarget.set(left);
833
- if (right && right.length === frames) {
834
- rightTarget.set(right);
939
+ if (left && left.length >= usable) {
940
+ leftTarget.set(left.subarray(0, usable));
941
+ if (right && right.length >= usable) {
942
+ rightTarget.set(right.subarray(0, usable));
835
943
  } else {
836
- rightTarget.set(left);
944
+ rightTarget.set(left.subarray(0, usable));
837
945
  }
838
946
  } else {
839
947
  leftTarget.fill(0);
@@ -841,14 +949,30 @@ export class SonareWorkletProcessor {
841
949
  }
842
950
  }
843
951
 
844
- this.realtime.process(frames);
845
- leftOut.set(this.realtime.outLeft);
846
- if (rightOut) {
847
- rightOut.set(this.realtime.outRight);
952
+ this.realtime.process(usable);
953
+ if (usable === frames) {
954
+ leftOut.set(this.realtime.outLeft.subarray(0, usable));
955
+ if (rightOut) {
956
+ rightOut.set(this.realtime.outRight.subarray(0, usable));
957
+ }
958
+ } else {
959
+ // frames > blockSize: fill the produced part and zero the remaining tail.
960
+ leftOut.fill(0);
961
+ leftOut.set(this.realtime.outLeft.subarray(0, usable));
962
+ if (rightOut) {
963
+ rightOut.fill(0);
964
+ rightOut.set(this.realtime.outRight.subarray(0, usable));
965
+ }
848
966
  }
849
- this.processedFrames += frames;
850
- this.publishMeter(this.realtime.outLeft, this.realtime.outRight);
851
- this.publishSpectrum(this.realtime.outLeft, this.realtime.outRight);
967
+ this.processedFrames += usable;
968
+ this.publishMeter(
969
+ this.realtime.outLeft.subarray(0, usable),
970
+ this.realtime.outRight.subarray(0, usable),
971
+ );
972
+ this.publishSpectrum(
973
+ this.realtime.outLeft.subarray(0, usable),
974
+ this.realtime.outRight.subarray(0, usable),
975
+ );
852
976
  return true;
853
977
  }
854
978
 
@@ -939,16 +1063,19 @@ export class SonareWorkletProcessor {
939
1063
  }
940
1064
  const writeIndex = Atomics.load(ring.header, 0);
941
1065
  const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
942
- ring.records[offset] = meter.frame;
1066
+ ring.records[offset] = encodeFrameLo(meter.frame);
943
1067
  ring.records[offset + 1] = meter.peakDbL;
944
1068
  ring.records[offset + 2] = meter.peakDbR;
945
1069
  ring.records[offset + 3] = meter.rmsDbL;
946
1070
  ring.records[offset + 4] = meter.rmsDbR;
947
1071
  ring.records[offset + 5] = meter.correlation;
1072
+ ring.records[offset + 6] = encodeFrameHi(meter.frame);
948
1073
  Atomics.store(ring.header, 0, writeIndex + 1);
949
- if (writeIndex + 1 > ring.capacity) {
950
- Atomics.store(ring.header, 3, writeIndex + 1 - ring.capacity);
951
- }
1074
+ // writeIndex is a free-running monotonic counter, so an overflow guard here
1075
+ // would fire on essentially every write past the first `capacity` records
1076
+ // and store an ever-growing value, not a dropped-record count. Readers
1077
+ // already detect silent overrun via firstReadable = max(readIndex,
1078
+ // writeIndex - capacity), so header slot 3 is left at its initial 0.
952
1079
  }
953
1080
 
954
1081
  private publishSpectrum(left: Float32Array, right: Float32Array): void {
@@ -974,8 +1101,20 @@ export class SonareWorkletProcessor {
974
1101
  }
975
1102
 
976
1103
  private computeSpectrum(left: Float32Array, right: Float32Array): void {
1104
+ // Coarse per-render-quantum band energy, NOT a full FFT analyzer: each band
1105
+ // is a single-bin DFT (bin = band + 1) evaluated over the current block of n
1106
+ // samples. Bins at or above the block Nyquist (band + 1 > floor(n / 2))
1107
+ // alias, so the evaluated band count is clamped to floor(n / 2) and any
1108
+ // higher bands are pinned to the silence floor. Bin resolution is therefore
1109
+ // tied to the render quantum (typically 128 samples); treat the output as a
1110
+ // rough spectral tilt, not a precise spectrum.
977
1111
  const n = Math.max(1, Math.min(left.length, right.length));
1112
+ const maxBand = Math.floor(n / 2);
978
1113
  for (let band = 0; band < this.spectrumBands.length; band++) {
1114
+ if (band >= maxBand) {
1115
+ this.spectrumBands[band] = magnitudeToDb(0);
1116
+ continue;
1117
+ }
979
1118
  const bin = band + 1;
980
1119
  let real = 0;
981
1120
  let imag = 0;
@@ -996,13 +1135,15 @@ export class SonareWorkletProcessor {
996
1135
  }
997
1136
  const writeIndex = Atomics.load(ring.header, 0);
998
1137
  const offset = (writeIndex % ring.capacity) * ring.recordFloats;
999
- ring.records[offset] = frame;
1000
- ring.records[offset + 1] = bands.length;
1001
- ring.records.set(bands.subarray(0, ring.bands), offset + 2);
1138
+ ring.records[offset] = encodeFrameLo(frame);
1139
+ ring.records[offset + 1] = encodeFrameHi(frame);
1140
+ ring.records[offset + 2] = bands.length;
1141
+ ring.records.set(bands.subarray(0, ring.bands), offset + 3);
1002
1142
  Atomics.store(ring.header, 0, writeIndex + 1);
1003
- if (writeIndex + 1 > ring.capacity) {
1004
- Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
1005
- }
1143
+ // See writeMeterRing: header slot 4 (the spectrum-ring overflow slot) is
1144
+ // left at its initial 0; readers detect silent overrun via the
1145
+ // firstReadable = max(readIndex, writeIndex - capacity) clamp. (Slot 3 here
1146
+ // holds the band count and is still written at ring creation.)
1006
1147
  }
1007
1148
  }
1008
1149
 
@@ -1023,17 +1164,21 @@ export class SonareRealtimeEngineWorkletProcessor {
1023
1164
  private closed = false;
1024
1165
  private commandRing?: SonareEngineCommandRingBuffer;
1025
1166
  private telemetryRing?: SonareEngineTelemetryRingBuffer;
1167
+ private meterRing?: SharedMeterRingWriter;
1026
1168
  private transport?: WorkletTransport;
1027
1169
  private meterIntervalFrames: number;
1028
1170
  private lastMeterFrame = Number.NEGATIVE_INFINITY;
1029
- // Pre-allocated worst-case input scratch buffers. The main thread allocates
1030
- // these in the constructor; process() reuses them via subarray() so it never
1031
- // touches the V8 heap allocator (which would risk GC stalls on the audio
1032
- // thread). One scratch buffer per channel sized to blockSize.
1033
- private readonly channelScratch: Float32Array[];
1034
- // Reused array of subarray views passed to engine.process() each block.
1035
- // Pre-allocating this array avoids growing-array allocations from .push().
1036
- private readonly channelScratchViews: Float32Array[];
1171
+ // Latest metronome gains/click length pushed via 'syncMetronome'. The
1172
+ // SetMetronome command only toggles enabled state; the config arrives here.
1173
+ private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1174
+ // Zero-copy prepared realtime path: persistent per-channel views onto the
1175
+ // engine's WASM-heap scratch (acquired once on the main thread via
1176
+ // getChannelBuffer). process() writes the AudioWorklet input straight into
1177
+ // these views, calls engine.processPrepared(frames) which runs the engine IN
1178
+ // PLACE, then reads the same views back — no std::vector or JS Float32Array is
1179
+ // allocated per render quantum (the old engine.process() round-tripped fresh
1180
+ // arrays on both heaps every block, an RT-safety hazard).
1181
+ private channelBuffers: Float32Array[];
1037
1182
 
1038
1183
  constructor(
1039
1184
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -1059,13 +1204,16 @@ export class SonareRealtimeEngineWorkletProcessor {
1059
1204
  options.telemetryRingCapacity,
1060
1205
  )
1061
1206
  : undefined;
1207
+ this.meterRing = options.meterSharedBuffer
1208
+ ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
1209
+ : undefined;
1062
1210
  this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
1063
- // Worst-case allocation: channelCount full-blockSize Float32Arrays.
1064
- this.channelScratch = new Array(this.channelCount);
1065
- this.channelScratchViews = new Array(this.channelCount);
1211
+ // Allocate persistent WASM-heap scratch (worst case: channelCount channels x
1212
+ // blockSize frames) and acquire the per-channel heap views once.
1213
+ this.engine.prepareChannels(this.channelCount, this.blockSize);
1214
+ this.channelBuffers = new Array(this.channelCount);
1066
1215
  for (let ch = 0; ch < this.channelCount; ch++) {
1067
- this.channelScratch[ch] = new Float32Array(this.blockSize);
1068
- this.channelScratchViews[ch] = this.channelScratch[ch];
1216
+ this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1069
1217
  }
1070
1218
  }
1071
1219
 
@@ -1093,39 +1241,51 @@ export class SonareRealtimeEngineWorkletProcessor {
1093
1241
  // `frames > this.blockSize` branch already returns early, so this is
1094
1242
  // defensive — but we warn once if it ever fires so the contract violation
1095
1243
  // is visible.
1096
- const scratchCapacity = this.channelScratch[0]?.length ?? 0;
1097
1244
  let usableFrames = frames;
1098
- if (usableFrames > scratchCapacity) {
1245
+ if (usableFrames > this.blockSize) {
1099
1246
  if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
1100
1247
  SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
1101
1248
  // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
1102
1249
  console.warn(
1103
1250
  `SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
1104
- `exceeds pre-allocated capacity ${scratchCapacity}; clamping.`,
1251
+ `exceeds pre-allocated capacity ${this.blockSize}; clamping.`,
1105
1252
  );
1106
1253
  }
1107
- usableFrames = scratchCapacity;
1254
+ usableFrames = this.blockSize;
1108
1255
  }
1256
+
1257
+ // Defend against WASM linear-memory growth detaching the cached heap views:
1258
+ // if any view's backing ArrayBuffer has been detached (byteLength === 0),
1259
+ // re-acquire all of them. This is a control-flow check (no allocation in the
1260
+ // common case where memory did not grow).
1261
+ if ((this.channelBuffers[0]?.byteLength ?? 0) === 0) {
1262
+ this.reacquireChannelBuffers();
1263
+ }
1264
+
1109
1265
  const input = inputs[0];
1110
- // Reuse the scratch buffers via subarray() these are views over the
1111
- // pre-allocated Float32Array storage and do not allocate on the heap.
1266
+ // Write the AudioWorklet input straight into the engine's WASM-heap views;
1267
+ // no per-block heap allocation.
1112
1268
  for (let ch = 0; ch < this.channelCount; ch++) {
1113
- const scratch = this.channelScratch[ch];
1269
+ const dst = this.channelBuffers[ch];
1114
1270
  const source = input?.[ch];
1115
1271
  if (source && source.length === usableFrames) {
1116
- scratch.set(source, 0);
1272
+ dst.set(source.subarray(0, usableFrames));
1117
1273
  } else {
1118
- scratch.fill(0, 0, usableFrames);
1274
+ dst.fill(0, 0, usableFrames);
1119
1275
  }
1120
- this.channelScratchViews[ch] = scratch.subarray(0, usableFrames);
1121
1276
  }
1122
1277
 
1123
- const processed = this.engine.process(this.channelScratchViews);
1278
+ // Run the engine in place over the prepared scratch (allocation-free).
1279
+ this.engine.processPrepared(usableFrames);
1280
+
1124
1281
  for (let ch = 0; ch < output.length; ch++) {
1125
1282
  const target = output[ch];
1126
- const source = processed[ch] ?? processed[0];
1283
+ const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
1127
1284
  if (source) {
1128
- target.set(source.subarray(0, target.length));
1285
+ target.set(source.subarray(0, Math.min(target.length, usableFrames)));
1286
+ if (target.length > usableFrames) {
1287
+ target.fill(0, usableFrames);
1288
+ }
1129
1289
  } else {
1130
1290
  target.fill(0);
1131
1291
  }
@@ -1135,12 +1295,44 @@ export class SonareRealtimeEngineWorkletProcessor {
1135
1295
  return true;
1136
1296
  }
1137
1297
 
1298
+ private reacquireChannelBuffers(): void {
1299
+ for (let ch = 0; ch < this.channelCount; ch++) {
1300
+ this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1301
+ }
1302
+ }
1303
+
1138
1304
  receiveCommand(command: SonareEngineCommandRecord): void {
1139
1305
  if (!this.closed) {
1140
1306
  this.applyCommand(command);
1141
1307
  }
1142
1308
  }
1143
1309
 
1310
+ // Applies an out-of-band control-plane sync message. Runs on the AudioWorklet
1311
+ // global scope but OUTSIDE process() (the message-port callback), so the
1312
+ // bulk/allocating engine setters (setClips/setMarkers) are safe here — they
1313
+ // never run on the realtime render path. This is the audio-thread equivalent
1314
+ // of the engine's control-thread RtPublisher setters.
1315
+ receiveSync(message: SonareEngineSyncMessage): void {
1316
+ if (this.closed) {
1317
+ return;
1318
+ }
1319
+ switch (message.type) {
1320
+ case 'syncClips':
1321
+ this.engine.setClips(message.clips);
1322
+ break;
1323
+ case 'syncMarkers':
1324
+ this.engine.setMarkers(message.markers);
1325
+ break;
1326
+ case 'syncMetronome':
1327
+ this.metronomeConfig = resolveMetronomeConfig(message.config);
1328
+ this.engine.setMetronome(message.config);
1329
+ break;
1330
+ case 'syncAutomation':
1331
+ this.engine.setAutomationLane(message.paramId, message.points);
1332
+ break;
1333
+ }
1334
+ }
1335
+
1144
1336
  destroy(): void {
1145
1337
  if (!this.closed) {
1146
1338
  this.engine.destroy();
@@ -1164,6 +1356,22 @@ export class SonareRealtimeEngineWorkletProcessor {
1164
1356
  private applyCommand(command: SonareEngineCommandRecord): void {
1165
1357
  const sampleTime = Number(command.sampleTime ?? -1);
1166
1358
  switch (command.type) {
1359
+ case SonareEngineCommandType.SetParam:
1360
+ // paramId is carried in targetId, the new value in argFloat (matches the
1361
+ // SonareEngine.setParam producer). sampleTime is the render frame.
1362
+ this.engine.setParameter(
1363
+ Math.trunc(Number(command.targetId ?? 0)),
1364
+ Number(command.argFloat ?? 0),
1365
+ sampleTime,
1366
+ );
1367
+ break;
1368
+ case SonareEngineCommandType.SetParamSmoothed:
1369
+ this.engine.setParameterSmoothed(
1370
+ Math.trunc(Number(command.targetId ?? 0)),
1371
+ Number(command.argFloat ?? 0),
1372
+ sampleTime,
1373
+ );
1374
+ break;
1167
1375
  case SonareEngineCommandType.TransportPlay:
1168
1376
  this.engine.play(sampleTime);
1169
1377
  break;
@@ -1190,20 +1398,31 @@ export class SonareRealtimeEngineWorkletProcessor {
1190
1398
  this.engine.armCapture(Boolean(command.argInt));
1191
1399
  break;
1192
1400
  case SonareEngineCommandType.Punch:
1401
+ // Both endpoints already arrive as samples (see SonareEngine.punch);
1402
+ // do NOT re-scale by sampleRate.
1193
1403
  this.engine.setCapturePunch(
1194
1404
  Number(command.argInt ?? 0),
1195
- Math.max(0, Math.round(Number(command.argFloat ?? 0) * this.sampleRate)),
1405
+ Math.max(0, Math.round(Number(command.argFloat ?? 0))),
1196
1406
  true,
1197
1407
  );
1198
1408
  break;
1199
1409
  case SonareEngineCommandType.SetMetronome:
1410
+ // Metronome config (beatGain/accentGain/clickSamples/clickSeconds) is
1411
+ // delivered out-of-band via the 'syncMetronome' message so it carries
1412
+ // the caller's full config; the command only toggles enabled state as a
1413
+ // sample-aligned fallback.
1200
1414
  this.engine.setMetronome({
1201
1415
  enabled: Boolean(command.argInt),
1202
- beatGain: 0.25,
1203
- accentGain: 0.75,
1204
- clickSamples: 64,
1416
+ beatGain: this.metronomeConfig.beatGain,
1417
+ accentGain: this.metronomeConfig.accentGain,
1418
+ clickSamples: this.metronomeConfig.clickSamples,
1205
1419
  });
1206
1420
  break;
1421
+ case SonareEngineCommandType.SeekMarker:
1422
+ // The realtime engine's markers are kept in sync via 'syncMarkers'
1423
+ // (RtPublisher-style swap), so a queued kSeekMarker resolves correctly.
1424
+ this.engine.seekMarker(Math.trunc(Number(command.targetId ?? 0)), sampleTime);
1425
+ break;
1207
1426
  default:
1208
1427
  this.publishTelemetryRecord({
1209
1428
  type: SonareEngineTelemetryType.Error,
@@ -1233,7 +1452,7 @@ export class SonareRealtimeEngineWorkletProcessor {
1233
1452
  }
1234
1453
 
1235
1454
  private publishMeters(): void {
1236
- if (!this.transport || this.meterIntervalFrames <= 0) {
1455
+ if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
1237
1456
  return;
1238
1457
  }
1239
1458
  for (const item of this.engine.drainMeterTelemetry(64)) {
@@ -1242,9 +1461,39 @@ export class SonareRealtimeEngineWorkletProcessor {
1242
1461
  continue;
1243
1462
  }
1244
1463
  this.lastMeterFrame = meter.frame;
1245
- this.transport.onMeter?.(meter);
1246
- this.transport.postMessage?.(meter);
1464
+ // Prefer the lock-free SAB meter ring (matching the telemetry path and
1465
+ // SonareWorkletProcessor); only fall back to structured-clone postMessage
1466
+ // when no ring was provided, so we do not allocate/post from the audio
1467
+ // render callback in SAB mode.
1468
+ if (this.meterRing) {
1469
+ this.writeMeterRing(meter);
1470
+ } else {
1471
+ this.transport?.onMeter?.(meter);
1472
+ this.transport?.postMessage?.(meter);
1473
+ }
1474
+ }
1475
+ }
1476
+
1477
+ private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
1478
+ const ring = this.meterRing;
1479
+ if (!ring) {
1480
+ return;
1247
1481
  }
1482
+ const writeIndex = Atomics.load(ring.header, 0);
1483
+ const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
1484
+ ring.records[offset] = encodeFrameLo(meter.frame);
1485
+ ring.records[offset + 1] = meter.peakDbL;
1486
+ ring.records[offset + 2] = meter.peakDbR;
1487
+ ring.records[offset + 3] = meter.rmsDbL;
1488
+ ring.records[offset + 4] = meter.rmsDbR;
1489
+ ring.records[offset + 5] = meter.correlation;
1490
+ ring.records[offset + 6] = encodeFrameHi(meter.frame);
1491
+ Atomics.store(ring.header, 0, writeIndex + 1);
1492
+ // writeIndex is a free-running monotonic counter, so an overflow guard here
1493
+ // would fire on essentially every write past the first `capacity` records
1494
+ // and store an ever-growing value, not a dropped-record count. Readers
1495
+ // already detect silent overrun via firstReadable = max(readIndex,
1496
+ // writeIndex - capacity), so header slot 3 is left at its initial 0.
1248
1497
  }
1249
1498
 
1250
1499
  private commandRingFromSharedBuffer(
@@ -1285,6 +1534,7 @@ export class SonareRtRealtimeEngineRuntime {
1285
1534
  private readonly telemetryFramesPtr: number;
1286
1535
  private readonly commandRing?: SonareEngineCommandRingBuffer;
1287
1536
  private readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1537
+ private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1288
1538
  private closed = false;
1289
1539
 
1290
1540
  constructor(options: SonareRtRealtimeEngineRuntimeOptions) {
@@ -1380,6 +1630,54 @@ export class SonareRtRealtimeEngineRuntime {
1380
1630
  return true;
1381
1631
  }
1382
1632
 
1633
+ receiveCommand(command: SonareEngineCommandRecord): void {
1634
+ if (!this.closed) {
1635
+ this.applyCommand(command);
1636
+ }
1637
+ }
1638
+
1639
+ // Out-of-band control sync for the sonare-rt runtime. The sonare-rt C ABI
1640
+ // (src/wasm/rt_bindings.cpp) exposes set_metronome_enabled and seek_marker but
1641
+ // NOT set_clips / set_markers, so clip/marker mutations cannot be applied to a
1642
+ // live sonare-rt engine. We honor the metronome config and surface a clear
1643
+ // telemetry error for the unsupported clip/marker paths instead of silently
1644
+ // dropping them. The default 'embind' runtime wires all three fully.
1645
+ receiveSync(message: SonareEngineSyncMessage): void {
1646
+ if (this.closed) {
1647
+ return;
1648
+ }
1649
+ switch (message.type) {
1650
+ case 'syncMetronome':
1651
+ this.metronomeConfig = resolveMetronomeConfig(message.config);
1652
+ this.module._sonare_rt_engine_set_metronome_enabled(
1653
+ this.engine,
1654
+ message.config.enabled ? 1 : 0,
1655
+ this.metronomeConfig.beatGain,
1656
+ this.metronomeConfig.accentGain,
1657
+ this.metronomeConfig.clickSamples,
1658
+ );
1659
+ break;
1660
+ case 'syncClips':
1661
+ case 'syncMarkers':
1662
+ case 'syncAutomation':
1663
+ // The sonare-rt C ABI exposes no set_clips / set_markers /
1664
+ // set_automation_lane, so these mutations cannot reach a live sonare-rt
1665
+ // engine. Surface a clear telemetry error rather than silently dropping.
1666
+ if (this.telemetryRing) {
1667
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1668
+ type: SonareEngineTelemetryType.Error,
1669
+ error: SonareEngineTelemetryError.UnknownTarget,
1670
+ renderFrame: 0,
1671
+ timelineSample: 0,
1672
+ audibleTimelineSample: 0,
1673
+ graphLatencySamplesQ8: 0,
1674
+ value: 0,
1675
+ });
1676
+ }
1677
+ break;
1678
+ }
1679
+ }
1680
+
1383
1681
  destroy(): void {
1384
1682
  if (this.closed) {
1385
1683
  return;
@@ -1418,6 +1716,25 @@ export class SonareRtRealtimeEngineRuntime {
1418
1716
  private applyCommand(command: SonareEngineCommandRecord): void {
1419
1717
  const sampleTime = toBigInt64(command.sampleTime, -1n);
1420
1718
  switch (command.type) {
1719
+ case SonareEngineCommandType.SetParam:
1720
+ case SonareEngineCommandType.SetParamSmoothed:
1721
+ // The sonare-rt C ABI (src/wasm/rt_bindings.cpp) does not export a
1722
+ // sonare_rt_engine_set_param entry point, so parameter automation has no
1723
+ // realtime transport on this runtime target. Surface a clear error
1724
+ // telemetry record (rather than silently dropping the command) so hosts
1725
+ // can detect the unsupported path; the embind runtime fully wires this.
1726
+ if (this.telemetryRing) {
1727
+ writeSonareEngineTelemetryRingBuffer(this.telemetryRing, {
1728
+ type: SonareEngineTelemetryType.Error,
1729
+ error: SonareEngineTelemetryError.UnknownTarget,
1730
+ renderFrame: 0,
1731
+ timelineSample: 0,
1732
+ audibleTimelineSample: 0,
1733
+ graphLatencySamplesQ8: 0,
1734
+ value: Number(command.type),
1735
+ });
1736
+ }
1737
+ break;
1421
1738
  case SonareEngineCommandType.TransportPlay:
1422
1739
  this.module._sonare_rt_engine_play(this.engine, sampleTime);
1423
1740
  break;
@@ -1453,10 +1770,12 @@ export class SonareRtRealtimeEngineRuntime {
1453
1770
  this.module._sonare_rt_engine_set_capture_armed(this.engine, command.argInt ? 1 : 0);
1454
1771
  break;
1455
1772
  case SonareEngineCommandType.Punch:
1773
+ // Both endpoints already arrive as samples (see SonareEngine.punch);
1774
+ // do NOT re-scale by sampleRate.
1456
1775
  this.module._sonare_rt_engine_set_capture_punch(
1457
1776
  this.engine,
1458
1777
  toBigInt64(command.argInt, 0n),
1459
- BigInt(Math.trunc(Number(command.argFloat ?? 0) * this.sampleRate)),
1778
+ BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
1460
1779
  1,
1461
1780
  );
1462
1781
  break;
@@ -1464,9 +1783,9 @@ export class SonareRtRealtimeEngineRuntime {
1464
1783
  this.module._sonare_rt_engine_set_metronome_enabled(
1465
1784
  this.engine,
1466
1785
  command.argInt ? 1 : 0,
1467
- 0.25,
1468
- 0.75,
1469
- 64,
1786
+ this.metronomeConfig.beatGain,
1787
+ this.metronomeConfig.accentGain,
1788
+ this.metronomeConfig.clickSamples,
1470
1789
  );
1471
1790
  break;
1472
1791
  case SonareEngineCommandType.SeekMarker:
@@ -1555,8 +1874,10 @@ export class SonareRealtimeEngineNode {
1555
1874
  readonly capabilities: SonareRealtimeEngineNodeCapabilities;
1556
1875
  readonly commandRing?: SonareEngineCommandRingBuffer;
1557
1876
  readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1877
+ readonly meterRing?: SonareMeterRingBuffer;
1558
1878
  readonly ready: Promise<void>;
1559
1879
  private telemetryReadIndex = 0;
1880
+ private meterReadIndex = 0;
1560
1881
  private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
1561
1882
  private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
1562
1883
  private resolveReady!: () => void;
@@ -1568,11 +1889,13 @@ export class SonareRealtimeEngineNode {
1568
1889
  capabilities: SonareRealtimeEngineNodeCapabilities,
1569
1890
  commandRing?: SonareEngineCommandRingBuffer,
1570
1891
  telemetryRing?: SonareEngineTelemetryRingBuffer,
1892
+ meterRing?: SonareMeterRingBuffer,
1571
1893
  ) {
1572
1894
  this.node = node;
1573
1895
  this.capabilities = capabilities;
1574
1896
  this.commandRing = commandRing;
1575
1897
  this.telemetryRing = telemetryRing;
1898
+ this.meterRing = meterRing;
1576
1899
  this.ready = new Promise((resolve, reject) => {
1577
1900
  this.resolveReady = resolve;
1578
1901
  this.rejectReady = reject;
@@ -1643,6 +1966,14 @@ export class SonareRealtimeEngineNode {
1643
1966
  mode === 'sab'
1644
1967
  ? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
1645
1968
  : undefined;
1969
+ // Meter ring: only the embind runtime publishes engine meters into a SAB
1970
+ // ring (the sonare-rt runtime owns its own meter transport). Lock-free
1971
+ // meter delivery matches the telemetry path and keeps the audio render
1972
+ // callback allocation-free in SAB mode.
1973
+ const meterRing =
1974
+ mode === 'sab' && runtimeTarget === 'embind'
1975
+ ? createSonareMeterRingBuffer(options.meterRingCapacity ?? 128)
1976
+ : undefined;
1646
1977
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1647
1978
  const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
1648
1979
  runtimeTarget,
@@ -1655,6 +1986,8 @@ export class SonareRealtimeEngineNode {
1655
1986
  commandRingCapacity: commandRing?.capacity,
1656
1987
  telemetrySharedBuffer: telemetryRing?.sharedBuffer,
1657
1988
  telemetryRingCapacity: telemetryRing?.capacity,
1989
+ meterSharedBuffer: meterRing?.sharedBuffer,
1990
+ meterRingCapacity: meterRing?.capacity,
1658
1991
  };
1659
1992
  const factory =
1660
1993
  options.nodeFactory ??
@@ -1681,6 +2014,7 @@ export class SonareRealtimeEngineNode {
1681
2014
  },
1682
2015
  commandRing,
1683
2016
  telemetryRing,
2017
+ meterRing,
1684
2018
  );
1685
2019
  }
1686
2020
 
@@ -1731,6 +2065,21 @@ export class SonareRealtimeEngineNode {
1731
2065
  return read.telemetry;
1732
2066
  }
1733
2067
 
2068
+ // Drains any meters published into the SAB meter ring (embind SAB mode) and
2069
+ // forwards them to onMeter listeners. In postMessage mode meters arrive via
2070
+ // node.port.onmessage instead, so this is a no-op then.
2071
+ pollMeters(): SonareWorkletMeterSnapshot[] {
2072
+ if (!this.meterRing) {
2073
+ return [];
2074
+ }
2075
+ const read = readSonareMeterRingBuffer(this.meterRing, this.meterReadIndex);
2076
+ this.meterReadIndex = read.nextReadIndex;
2077
+ for (const meter of read.meters) {
2078
+ this.emitMeter(meter);
2079
+ }
2080
+ return read.meters;
2081
+ }
2082
+
1734
2083
  onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
1735
2084
  this.telemetryListeners.add(callback);
1736
2085
  return () => {
@@ -1866,6 +2215,15 @@ export class SonareEngine {
1866
2215
 
1867
2216
  setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
1868
2217
  this.offlineEngine.setLoop(startPpq, endPpq, enabled);
2218
+ // Transport precision contract: the SAB command record carries exactly one
2219
+ // Float64 lane (argFloat) and one Int64 lane (argInt). startPpq travels in
2220
+ // argFloat with full double precision, matching the offline engine; endPpq
2221
+ // is carried as micro-PPQ (round(endPpq * 1e6)) in the integer lane and
2222
+ // divided back by 1e6 on the consumer. Loop ENDS are therefore snapped to
2223
+ // the nearest 1e-6 PPQ over the realtime transport (max 5e-7 PPQ drift),
2224
+ // while loop STARTS and the offline path stay exact. This is intentional:
2225
+ // the record has no second free Float64 lane, and a micro-PPQ grid on the
2226
+ // loop end is well below audible/sample-accurate resolution at any tempo.
1869
2227
  return this.realtimeNode.sendCommand({
1870
2228
  type: SonareEngineCommandType.SetLoop,
1871
2229
  targetId: enabled ? 1 : 0,
@@ -1876,10 +2234,17 @@ export class SonareEngine {
1876
2234
  }
1877
2235
 
1878
2236
  setParam(nodeId: string, param: string | number, value: number): boolean {
1879
- void nodeId;
1880
- void param;
1881
- void value;
1882
- return false;
2237
+ const paramId = this.resolveParamId(nodeId, param);
2238
+ // Mirror the change into the offline engine so a subsequent offline render
2239
+ // reflects the live value, then push a sample-accurate command to the
2240
+ // realtime runtime (mirrors setTempo/setLoop above).
2241
+ this.offlineEngine.setParameter(paramId, value);
2242
+ return this.realtimeNode.sendCommand({
2243
+ type: SonareEngineCommandType.SetParam,
2244
+ targetId: paramId,
2245
+ sampleTime: -1,
2246
+ argFloat: value,
2247
+ });
1883
2248
  }
1884
2249
 
1885
2250
  scheduleParam(
@@ -1895,6 +2260,11 @@ export class SonareEngine {
1895
2260
  lane.sort((a, b) => a.ppq - b.ppq);
1896
2261
  this.automationLanes.set(paramId, lane);
1897
2262
  this.offlineEngine.setAutomationLane(paramId, lane);
2263
+ // Mirror the lane to the live worklet engine so scheduled automation plays
2264
+ // back in real time, not just in renderOffline(). Lanes can exceed the
2265
+ // fixed-size SAB command record, so they ride an out-of-band 'syncAutomation'
2266
+ // message applied outside process() (like syncClips/syncMarkers).
2267
+ this.postSync({ type: 'syncAutomation', paramId, points: lane });
1898
2268
  }
1899
2269
 
1900
2270
  addAutomationPoint(
@@ -1918,7 +2288,16 @@ export class SonareEngine {
1918
2288
  void target;
1919
2289
  void solo;
1920
2290
  void mute;
1921
- return false;
2291
+ // Per-track solo/mute is a Mixer concept (strip-indexed setSoloed/setMuted),
2292
+ // not an engine command: the WASM SonareEngine facade does not own a Mixer
2293
+ // node, so there is no realtime transport for solo/mute on this surface.
2294
+ // Throw a clear error instead of silently returning false (a silent no-op
2295
+ // would leave callers believing the change took effect). Apply solo/mute
2296
+ // directly on a Mixer instance (Mixer.setSoloed/Mixer.setMuted) instead.
2297
+ throw new Error(
2298
+ 'SonareEngine.setSoloMute is not supported: solo/mute is a Mixer feature; ' +
2299
+ 'use Mixer.setSoloed(stripIndex, ...) / Mixer.setMuted(stripIndex, ...) instead.',
2300
+ );
1922
2301
  }
1923
2302
 
1924
2303
  addClip(
@@ -1959,16 +2338,25 @@ export class SonareEngine {
1959
2338
  const inSample = this.ppqToApproxSample(inPpq);
1960
2339
  const outSample = this.ppqToApproxSample(outPpq);
1961
2340
  this.offlineEngine.setCapturePunch(inSample, outSample, true);
2341
+ // Carry BOTH endpoints as already-converted SAMPLES so the realtime engine
2342
+ // agrees with the offline engine. The previous code sent the raw PPQ out
2343
+ // point and let the consumer multiply by sampleRate (treating PPQ as
2344
+ // seconds), which ignored tempo and produced a punch-out ~2x too large at
2345
+ // 120 BPM. argInt = in sample, argFloat = out sample (full-precision double).
1962
2346
  return this.realtimeNode.sendCommand({
1963
2347
  type: SonareEngineCommandType.Punch,
1964
2348
  sampleTime: -1,
1965
2349
  argInt: inSample,
1966
- argFloat: outPpq,
2350
+ argFloat: outSample,
1967
2351
  });
1968
2352
  }
1969
2353
 
1970
2354
  setMetronome(opts: EngineMetronomeConfig): void {
1971
2355
  this.offlineEngine.setMetronome(opts);
2356
+ // The full config (beatGain/accentGain/clickSamples/clickSeconds) cannot fit
2357
+ // the fixed-size SAB command record, so it is delivered out-of-band; the
2358
+ // SetMetronome command then toggles enabled state on the audio thread.
2359
+ this.postSync({ type: 'syncMetronome', config: opts });
1972
2360
  this.realtimeNode.sendCommand({
1973
2361
  type: SonareEngineCommandType.SetMetronome,
1974
2362
  sampleTime: -1,
@@ -1985,7 +2373,15 @@ export class SonareEngine {
1985
2373
 
1986
2374
  seekMarker(markerId: number): boolean {
1987
2375
  this.offlineEngine.seekMarker(markerId);
1988
- return false;
2376
+ // Forward to the live worklet engine. Its marker set is kept in sync via the
2377
+ // 'syncMarkers' message (see syncMarkers), so a queued kSeekMarker resolves
2378
+ // the marker id to its frame on the audio thread. Returns the sendCommand
2379
+ // result (previously this method always returned a misleading `false`).
2380
+ return this.realtimeNode.sendCommand({
2381
+ type: SonareEngineCommandType.SeekMarker,
2382
+ targetId: markerId,
2383
+ sampleTime: -1,
2384
+ });
1989
2385
  }
1990
2386
 
1991
2387
  async renderOffline(totalFrames: number): Promise<Float32Array[]> {
@@ -2009,6 +2405,10 @@ export class SonareEngine {
2009
2405
  return this.realtimeNode.pollTelemetry();
2010
2406
  }
2011
2407
 
2408
+ pollMeters(): SonareWorkletMeterSnapshot[] {
2409
+ return this.realtimeNode.pollMeters();
2410
+ }
2411
+
2012
2412
  destroy(): void {
2013
2413
  if (this.destroyed) {
2014
2414
  return;
@@ -2021,11 +2421,29 @@ export class SonareEngine {
2021
2421
  }
2022
2422
 
2023
2423
  private syncClips(): void {
2024
- this.offlineEngine.setClips(Array.from(this.clips.values()));
2424
+ const clips = Array.from(this.clips.values());
2425
+ this.offlineEngine.setClips(clips);
2426
+ // Push the full clip set to the live worklet engine over the message port.
2427
+ // Clip audio buffers are too large for the fixed-size SAB command record, so
2428
+ // they ride a structured-clone 'syncClips' message applied OUTSIDE the audio
2429
+ // render callback (the audio-thread equivalent of an RtPublisher swap).
2430
+ this.postSync({ type: 'syncClips', clips });
2025
2431
  }
2026
2432
 
2027
2433
  private syncMarkers(): void {
2028
- this.offlineEngine.setMarkers(Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq));
2434
+ const markers = Array.from(this.markers.values()).sort((a, b) => a.ppq - b.ppq);
2435
+ this.offlineEngine.setMarkers(markers);
2436
+ this.postSync({ type: 'syncMarkers', markers });
2437
+ }
2438
+
2439
+ // Posts an out-of-band control-sync message to the worklet engine processor.
2440
+ // Sync messages use a string `type` so the worklet's message handler routes
2441
+ // them to receiveSync() (numeric `type` is reserved for SonareEngineCommandRecord).
2442
+ private postSync(message: SonareEngineSyncMessage): void {
2443
+ if (this.destroyed) {
2444
+ return;
2445
+ }
2446
+ this.realtimeNode.node.port.postMessage(message);
2029
2447
  }
2030
2448
 
2031
2449
  private resolveParamId(nodeId: string, param: string | number): number {
@@ -2127,6 +2545,14 @@ export class SonareRealtimeVoiceChangerWorkletProcessor {
2127
2545
  return !this.destroyed;
2128
2546
  }
2129
2547
 
2548
+ // The cached heap views can detach if WASM linear memory grows (the embind
2549
+ // module is built ALLOW_MEMORY_GROWTH). Re-acquire them if detached
2550
+ // (byteLength === 0) before touching them; in the common no-growth case this
2551
+ // is a cheap branch with no allocation.
2552
+ if (this.monoInput.byteLength === 0) {
2553
+ this.reacquireBuffers();
2554
+ }
2555
+
2130
2556
  const input = inputs[0];
2131
2557
  const requestedFrames = output[0]?.length ?? 0;
2132
2558
  const requestedChannels = Math.min(this.channelCount, output.length);
@@ -2188,6 +2614,20 @@ export class SonareRealtimeVoiceChangerWorkletProcessor {
2188
2614
  this.changer.delete();
2189
2615
  }
2190
2616
 
2617
+ // Re-acquires the cached WASM-heap views after a memory-growth detachment.
2618
+ // The underlying C++ vectors are pre-warmed (ensure_*_capacity ran at prepare
2619
+ // time), so getMono*/getPlanar* return fresh views onto the SAME storage
2620
+ // without reallocating it.
2621
+ private reacquireBuffers(): void {
2622
+ this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
2623
+ this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
2624
+ if (this.channelCount > 1) {
2625
+ for (let ch = 0; ch < this.channelCount; ch++) {
2626
+ this.planarChannels[ch] = this.changer.getPlanarChannelBuffer(ch, this.blockSize);
2627
+ }
2628
+ }
2629
+ }
2630
+
2191
2631
  /**
2192
2632
  * Returns the number of frames we can actually process given the
2193
2633
  * pre-allocated capacity. If the host requests more frames than the
@@ -2345,6 +2785,10 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2345
2785
  const onMessage = (event: { data: unknown }) => {
2346
2786
  if (isEngineCommandRecord(event.data)) {
2347
2787
  this.bridge?.receiveCommand(event.data);
2788
+ this.rtBridge?.receiveCommand(event.data);
2789
+ } else if (isEngineSyncMessage(event.data)) {
2790
+ this.bridge?.receiveSync(event.data);
2791
+ this.rtBridge?.receiveSync(event.data);
2348
2792
  }
2349
2793
  };
2350
2794
  if (port?.addEventListener) {