@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/src/worklet.ts CHANGED
@@ -7,8 +7,9 @@ import type {
7
7
  EngineParameterInfo,
8
8
  EngineTelemetry,
9
9
  MixerRealtimeBuffer,
10
+ RealtimeVoiceChangerConfigInput,
10
11
  } from './index';
11
- import { engineCapabilities, Mixer, RealtimeEngine } from './index';
12
+ import { engineCapabilities, Mixer, RealtimeEngine, RealtimeVoiceChanger } from './index';
12
13
  import type { AutomationCurve } from './public_types';
13
14
  import type { SonareRtModule } from './sonare-rt';
14
15
 
@@ -45,8 +46,35 @@ export interface SonareRealtimeEngineWorkletProcessorOptions {
45
46
  commandRingCapacity?: number;
46
47
  telemetrySharedBuffer?: SharedArrayBuffer;
47
48
  telemetryRingCapacity?: number;
49
+ meterSharedBuffer?: SharedArrayBuffer;
50
+ meterRingCapacity?: number;
51
+ }
52
+
53
+ export interface SonareRealtimeVoiceChangerWorkletProcessorOptions {
54
+ preset?: RealtimeVoiceChangerConfigInput;
55
+ sampleRate?: number;
56
+ blockSize?: number;
57
+ channelCount?: number;
58
+ }
59
+
60
+ export interface SonareRealtimeVoiceChangerSetConfigMessage {
61
+ type: 'setConfig';
62
+ preset: RealtimeVoiceChangerConfigInput;
48
63
  }
49
64
 
65
+ export interface SonareRealtimeVoiceChangerResetMessage {
66
+ type: 'reset';
67
+ }
68
+
69
+ export interface SonareRealtimeVoiceChangerDestroyMessage {
70
+ type: 'destroy';
71
+ }
72
+
73
+ export type SonareRealtimeVoiceChangerMessage =
74
+ | SonareRealtimeVoiceChangerSetConfigMessage
75
+ | SonareRealtimeVoiceChangerResetMessage
76
+ | SonareRealtimeVoiceChangerDestroyMessage;
77
+
50
78
  export interface SonareRealtimeEngineNodeCapabilities {
51
79
  mode: 'sab' | 'postMessage';
52
80
  runtimeTarget: 'embind' | 'sonare-rt';
@@ -156,8 +184,33 @@ export type SonareWorkletTransportMessage =
156
184
  | SonareEngineTelemetryRecord;
157
185
 
158
186
  export const SONARE_METER_RING_HEADER_INTS = 4;
159
- 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;
160
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
+ }
161
214
  export const SONARE_ENGINE_RING_HEADER_INTS = 5;
162
215
  export const SONARE_ENGINE_COMMAND_RECORD_BYTES = 32;
163
216
  export const SONARE_ENGINE_TELEMETRY_RECORD_BYTES = 48;
@@ -211,6 +264,29 @@ interface WorkletTransport {
211
264
  onSpectrum?: (spectrum: SonareWorkletSpectrumSnapshot) => void;
212
265
  }
213
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
+
214
290
  export interface SonareMeterRingBuffer {
215
291
  sharedBuffer: SharedArrayBuffer;
216
292
  header: Int32Array;
@@ -244,6 +320,39 @@ export interface SonareEngineCommandRecord {
244
320
  argInt?: number | bigint;
245
321
  }
246
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
+
247
356
  export interface SonareEngineTelemetryRecord {
248
357
  type: SonareEngineTelemetryType | number;
249
358
  error: SonareEngineTelemetryError | number;
@@ -317,6 +426,25 @@ function isEngineCommandRecord(value: unknown): value is SonareEngineCommandReco
317
426
  return isRecord(value) && typeof value.type === 'number';
318
427
  }
319
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
+
441
+ function isRealtimeVoiceChangerMessage(value: unknown): value is SonareRealtimeVoiceChangerMessage {
442
+ if (!isRecord(value) || typeof value.type !== 'string') {
443
+ return false;
444
+ }
445
+ return value.type === 'setConfig' || value.type === 'reset' || value.type === 'destroy';
446
+ }
447
+
320
448
  function isEngineTelemetryRecord(value: unknown): value is SonareEngineTelemetryRecord {
321
449
  return (
322
450
  isRecord(value) &&
@@ -374,7 +502,7 @@ export function readSonareMeterRingBuffer(
374
502
  const offset = (index % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
375
503
  meters.push({
376
504
  type: 'meter',
377
- frame: ring.records[offset],
505
+ frame: decodeFrame(ring.records[offset], ring.records[offset + 6]),
378
506
  peakDbL: ring.records[offset + 1],
379
507
  peakDbR: ring.records[offset + 2],
380
508
  rmsDbL: ring.records[offset + 3],
@@ -388,9 +516,11 @@ export function readSonareMeterRingBuffer(
388
516
  export function sonareSpectrumRingBufferByteLength(capacity: number, bands = 16): number {
389
517
  const clampedCapacity = Math.max(1, Math.floor(capacity));
390
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.
391
521
  return (
392
522
  SONARE_SPECTRUM_RING_HEADER_INTS * Int32Array.BYTES_PER_ELEMENT +
393
- clampedCapacity * (2 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
523
+ clampedCapacity * (3 + clampedBands) * Float32Array.BYTES_PER_ELEMENT
394
524
  );
395
525
  }
396
526
 
@@ -423,7 +553,7 @@ export function readSonareSpectrumRingBuffer(
423
553
  readIndex = 0,
424
554
  ): SonareSpectrumRingReadResult {
425
555
  const writeIndex = Atomics.load(ring.header, 0);
426
- const recordFloats = Atomics.load(ring.header, 2) || 2 + ring.bands;
556
+ const recordFloats = Atomics.load(ring.header, 2) || 3 + ring.bands;
427
557
  const bands = Atomics.load(ring.header, 3) || ring.bands;
428
558
  const nextReadIndex = Math.max(0, Math.min(readIndex, writeIndex));
429
559
  const firstReadable = Math.max(nextReadIndex, writeIndex - ring.capacity);
@@ -431,8 +561,12 @@ export function readSonareSpectrumRingBuffer(
431
561
  for (let index = firstReadable; index < writeIndex; index++) {
432
562
  const offset = (index % ring.capacity) * recordFloats;
433
563
  const values = new Float32Array(bands);
434
- values.set(ring.records.subarray(offset + 2, offset + 2 + bands));
435
- 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
+ });
436
570
  }
437
571
  return { nextReadIndex: writeIndex, spectra };
438
572
  }
@@ -587,7 +721,7 @@ function spectrumRingFromSharedBuffer(
587
721
  const existingBands = Atomics.load(header, 3);
588
722
  const capacity = Math.max(1, Math.floor(existingCapacity || fallbackCapacity || 1));
589
723
  const bands = Math.max(1, Math.floor(existingBands || fallbackBands || 16));
590
- const recordFloats = 2 + bands;
724
+ const recordFloats = 3 + bands;
591
725
  const minBytes = sonareSpectrumRingBufferByteLength(capacity, bands);
592
726
  if (sharedBuffer.byteLength < minBytes) {
593
727
  throw new Error('spectrumSharedBuffer is too small for the requested ring capacity.');
@@ -648,8 +782,10 @@ function writeEngineCommandRecord(
648
782
  view.setUint32(offset, command.type, true);
649
783
  view.setUint32(offset + 4, command.targetId ?? 0, true);
650
784
  view.setBigInt64(offset + 8, toBigInt64(command.sampleTime, -1n), true);
651
- view.setFloat32(offset + 16, command.argFloat ?? 0, true);
652
- 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);
653
789
  view.setBigInt64(offset + 24, toBigInt64(command.argInt, 0n), true);
654
790
  }
655
791
 
@@ -658,7 +794,7 @@ function readEngineCommandRecord(view: DataView, offset: number): SonareEngineCo
658
794
  type: view.getUint32(offset, true),
659
795
  targetId: view.getUint32(offset + 4, true),
660
796
  sampleTime: Number(view.getBigInt64(offset + 8, true)),
661
- argFloat: view.getFloat32(offset + 16, true),
797
+ argFloat: view.getFloat64(offset + 16, true),
662
798
  argInt: Number(view.getBigInt64(offset + 24, true)),
663
799
  };
664
800
  }
@@ -785,9 +921,14 @@ export class SonareWorkletProcessor {
785
921
  return true;
786
922
  }
787
923
  const frames = leftOut.length;
788
- if (frames !== this.blockSize) {
789
- return false;
790
- }
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);
791
932
 
792
933
  for (let strip = 0; strip < this.realtime.leftInputs.length; strip++) {
793
934
  const input = inputs[strip];
@@ -795,12 +936,12 @@ export class SonareWorkletProcessor {
795
936
  const right = input?.[1];
796
937
  const leftTarget = this.realtime.leftInputs[strip];
797
938
  const rightTarget = this.realtime.rightInputs[strip];
798
- if (left && left.length === frames) {
799
- leftTarget.set(left);
800
- if (right && right.length === frames) {
801
- 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));
802
943
  } else {
803
- rightTarget.set(left);
944
+ rightTarget.set(left.subarray(0, usable));
804
945
  }
805
946
  } else {
806
947
  leftTarget.fill(0);
@@ -808,14 +949,30 @@ export class SonareWorkletProcessor {
808
949
  }
809
950
  }
810
951
 
811
- this.realtime.process(frames);
812
- leftOut.set(this.realtime.outLeft);
813
- if (rightOut) {
814
- 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
+ }
815
966
  }
816
- this.processedFrames += frames;
817
- this.publishMeter(this.realtime.outLeft, this.realtime.outRight);
818
- 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
+ );
819
976
  return true;
820
977
  }
821
978
 
@@ -906,16 +1063,19 @@ export class SonareWorkletProcessor {
906
1063
  }
907
1064
  const writeIndex = Atomics.load(ring.header, 0);
908
1065
  const offset = (writeIndex % ring.capacity) * SONARE_METER_RING_RECORD_FLOATS;
909
- ring.records[offset] = meter.frame;
1066
+ ring.records[offset] = encodeFrameLo(meter.frame);
910
1067
  ring.records[offset + 1] = meter.peakDbL;
911
1068
  ring.records[offset + 2] = meter.peakDbR;
912
1069
  ring.records[offset + 3] = meter.rmsDbL;
913
1070
  ring.records[offset + 4] = meter.rmsDbR;
914
1071
  ring.records[offset + 5] = meter.correlation;
1072
+ ring.records[offset + 6] = encodeFrameHi(meter.frame);
915
1073
  Atomics.store(ring.header, 0, writeIndex + 1);
916
- if (writeIndex + 1 > ring.capacity) {
917
- Atomics.store(ring.header, 3, writeIndex + 1 - ring.capacity);
918
- }
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.
919
1079
  }
920
1080
 
921
1081
  private publishSpectrum(left: Float32Array, right: Float32Array): void {
@@ -941,8 +1101,20 @@ export class SonareWorkletProcessor {
941
1101
  }
942
1102
 
943
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.
944
1111
  const n = Math.max(1, Math.min(left.length, right.length));
1112
+ const maxBand = Math.floor(n / 2);
945
1113
  for (let band = 0; band < this.spectrumBands.length; band++) {
1114
+ if (band >= maxBand) {
1115
+ this.spectrumBands[band] = magnitudeToDb(0);
1116
+ continue;
1117
+ }
946
1118
  const bin = band + 1;
947
1119
  let real = 0;
948
1120
  let imag = 0;
@@ -963,13 +1135,15 @@ export class SonareWorkletProcessor {
963
1135
  }
964
1136
  const writeIndex = Atomics.load(ring.header, 0);
965
1137
  const offset = (writeIndex % ring.capacity) * ring.recordFloats;
966
- ring.records[offset] = frame;
967
- ring.records[offset + 1] = bands.length;
968
- 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);
969
1142
  Atomics.store(ring.header, 0, writeIndex + 1);
970
- if (writeIndex + 1 > ring.capacity) {
971
- Atomics.store(ring.header, 4, writeIndex + 1 - ring.capacity);
972
- }
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.)
973
1147
  }
974
1148
  }
975
1149
 
@@ -981,6 +1155,7 @@ export class SonareWorkletProcessor {
981
1155
  * load the dedicated Emscripten AudioWorklet module.
982
1156
  */
983
1157
  export class SonareRealtimeEngineWorkletProcessor {
1158
+ private static warnedChannelScratchOverflow = false;
984
1159
  readonly sampleRate: number;
985
1160
  readonly blockSize: number;
986
1161
  readonly channelCount: number;
@@ -989,9 +1164,21 @@ export class SonareRealtimeEngineWorkletProcessor {
989
1164
  private closed = false;
990
1165
  private commandRing?: SonareEngineCommandRingBuffer;
991
1166
  private telemetryRing?: SonareEngineTelemetryRingBuffer;
1167
+ private meterRing?: SharedMeterRingWriter;
992
1168
  private transport?: WorkletTransport;
993
1169
  private meterIntervalFrames: number;
994
1170
  private lastMeterFrame = Number.NEGATIVE_INFINITY;
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[];
995
1182
 
996
1183
  constructor(
997
1184
  options: SonareRealtimeEngineWorkletProcessorOptions = {},
@@ -1017,7 +1204,17 @@ export class SonareRealtimeEngineWorkletProcessor {
1017
1204
  options.telemetryRingCapacity,
1018
1205
  )
1019
1206
  : undefined;
1207
+ this.meterRing = options.meterSharedBuffer
1208
+ ? meterRingFromSharedBuffer(options.meterSharedBuffer, options.meterRingCapacity)
1209
+ : undefined;
1020
1210
  this.engine = new RealtimeEngine(this.sampleRate, this.blockSize);
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);
1215
+ for (let ch = 0; ch < this.channelCount; ch++) {
1216
+ this.channelBuffers[ch] = this.engine.getChannelBuffer(ch, this.blockSize);
1217
+ }
1021
1218
  }
1022
1219
 
1023
1220
  process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
@@ -1040,23 +1237,55 @@ export class SonareRealtimeEngineWorkletProcessor {
1040
1237
 
1041
1238
  this.drainCommands();
1042
1239
 
1043
- const channels: Float32Array[] = [];
1240
+ // Clamp `frames` to the pre-allocated scratch capacity. The earlier
1241
+ // `frames > this.blockSize` branch already returns early, so this is
1242
+ // defensive — but we warn once if it ever fires so the contract violation
1243
+ // is visible.
1244
+ let usableFrames = frames;
1245
+ if (usableFrames > this.blockSize) {
1246
+ if (!SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow) {
1247
+ SonareRealtimeEngineWorkletProcessor.warnedChannelScratchOverflow = true;
1248
+ // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
1249
+ console.warn(
1250
+ `SonareRealtimeEngineWorkletProcessor: requested ${usableFrames} frames ` +
1251
+ `exceeds pre-allocated capacity ${this.blockSize}; clamping.`,
1252
+ );
1253
+ }
1254
+ usableFrames = this.blockSize;
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
+
1044
1265
  const input = inputs[0];
1266
+ // Write the AudioWorklet input straight into the engine's WASM-heap views;
1267
+ // no per-block heap allocation.
1045
1268
  for (let ch = 0; ch < this.channelCount; ch++) {
1269
+ const dst = this.channelBuffers[ch];
1046
1270
  const source = input?.[ch];
1047
- const channel = new Float32Array(frames);
1048
- if (source && source.length === frames) {
1049
- channel.set(source);
1271
+ if (source && source.length === usableFrames) {
1272
+ dst.set(source.subarray(0, usableFrames));
1273
+ } else {
1274
+ dst.fill(0, 0, usableFrames);
1050
1275
  }
1051
- channels.push(channel);
1052
1276
  }
1053
1277
 
1054
- const processed = this.engine.process(channels);
1278
+ // Run the engine in place over the prepared scratch (allocation-free).
1279
+ this.engine.processPrepared(usableFrames);
1280
+
1055
1281
  for (let ch = 0; ch < output.length; ch++) {
1056
1282
  const target = output[ch];
1057
- const source = processed[ch] ?? processed[0];
1283
+ const source = this.channelBuffers[ch] ?? this.channelBuffers[0];
1058
1284
  if (source) {
1059
- 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
+ }
1060
1289
  } else {
1061
1290
  target.fill(0);
1062
1291
  }
@@ -1066,12 +1295,44 @@ export class SonareRealtimeEngineWorkletProcessor {
1066
1295
  return true;
1067
1296
  }
1068
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
+
1069
1304
  receiveCommand(command: SonareEngineCommandRecord): void {
1070
1305
  if (!this.closed) {
1071
1306
  this.applyCommand(command);
1072
1307
  }
1073
1308
  }
1074
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
+
1075
1336
  destroy(): void {
1076
1337
  if (!this.closed) {
1077
1338
  this.engine.destroy();
@@ -1095,6 +1356,22 @@ export class SonareRealtimeEngineWorkletProcessor {
1095
1356
  private applyCommand(command: SonareEngineCommandRecord): void {
1096
1357
  const sampleTime = Number(command.sampleTime ?? -1);
1097
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;
1098
1375
  case SonareEngineCommandType.TransportPlay:
1099
1376
  this.engine.play(sampleTime);
1100
1377
  break;
@@ -1121,20 +1398,31 @@ export class SonareRealtimeEngineWorkletProcessor {
1121
1398
  this.engine.armCapture(Boolean(command.argInt));
1122
1399
  break;
1123
1400
  case SonareEngineCommandType.Punch:
1401
+ // Both endpoints already arrive as samples (see SonareEngine.punch);
1402
+ // do NOT re-scale by sampleRate.
1124
1403
  this.engine.setCapturePunch(
1125
1404
  Number(command.argInt ?? 0),
1126
- Math.max(0, Math.round(Number(command.argFloat ?? 0) * this.sampleRate)),
1405
+ Math.max(0, Math.round(Number(command.argFloat ?? 0))),
1127
1406
  true,
1128
1407
  );
1129
1408
  break;
1130
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.
1131
1414
  this.engine.setMetronome({
1132
1415
  enabled: Boolean(command.argInt),
1133
- beatGain: 0.25,
1134
- accentGain: 0.75,
1135
- clickSamples: 64,
1416
+ beatGain: this.metronomeConfig.beatGain,
1417
+ accentGain: this.metronomeConfig.accentGain,
1418
+ clickSamples: this.metronomeConfig.clickSamples,
1136
1419
  });
1137
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;
1138
1426
  default:
1139
1427
  this.publishTelemetryRecord({
1140
1428
  type: SonareEngineTelemetryType.Error,
@@ -1164,7 +1452,7 @@ export class SonareRealtimeEngineWorkletProcessor {
1164
1452
  }
1165
1453
 
1166
1454
  private publishMeters(): void {
1167
- if (!this.transport || this.meterIntervalFrames <= 0) {
1455
+ if (this.meterIntervalFrames <= 0 || (!this.transport && !this.meterRing)) {
1168
1456
  return;
1169
1457
  }
1170
1458
  for (const item of this.engine.drainMeterTelemetry(64)) {
@@ -1173,11 +1461,41 @@ export class SonareRealtimeEngineWorkletProcessor {
1173
1461
  continue;
1174
1462
  }
1175
1463
  this.lastMeterFrame = meter.frame;
1176
- this.transport.onMeter?.(meter);
1177
- 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
+ }
1178
1474
  }
1179
1475
  }
1180
1476
 
1477
+ private writeMeterRing(meter: SonareWorkletMeterSnapshot): void {
1478
+ const ring = this.meterRing;
1479
+ if (!ring) {
1480
+ return;
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.
1497
+ }
1498
+
1181
1499
  private commandRingFromSharedBuffer(
1182
1500
  sharedBuffer: SharedArrayBuffer,
1183
1501
  fallbackCapacity?: number,
@@ -1216,6 +1534,7 @@ export class SonareRtRealtimeEngineRuntime {
1216
1534
  private readonly telemetryFramesPtr: number;
1217
1535
  private readonly commandRing?: SonareEngineCommandRingBuffer;
1218
1536
  private readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1537
+ private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
1219
1538
  private closed = false;
1220
1539
 
1221
1540
  constructor(options: SonareRtRealtimeEngineRuntimeOptions) {
@@ -1311,6 +1630,54 @@ export class SonareRtRealtimeEngineRuntime {
1311
1630
  return true;
1312
1631
  }
1313
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
+
1314
1681
  destroy(): void {
1315
1682
  if (this.closed) {
1316
1683
  return;
@@ -1349,6 +1716,25 @@ export class SonareRtRealtimeEngineRuntime {
1349
1716
  private applyCommand(command: SonareEngineCommandRecord): void {
1350
1717
  const sampleTime = toBigInt64(command.sampleTime, -1n);
1351
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;
1352
1738
  case SonareEngineCommandType.TransportPlay:
1353
1739
  this.module._sonare_rt_engine_play(this.engine, sampleTime);
1354
1740
  break;
@@ -1384,10 +1770,12 @@ export class SonareRtRealtimeEngineRuntime {
1384
1770
  this.module._sonare_rt_engine_set_capture_armed(this.engine, command.argInt ? 1 : 0);
1385
1771
  break;
1386
1772
  case SonareEngineCommandType.Punch:
1773
+ // Both endpoints already arrive as samples (see SonareEngine.punch);
1774
+ // do NOT re-scale by sampleRate.
1387
1775
  this.module._sonare_rt_engine_set_capture_punch(
1388
1776
  this.engine,
1389
1777
  toBigInt64(command.argInt, 0n),
1390
- BigInt(Math.trunc(Number(command.argFloat ?? 0) * this.sampleRate)),
1778
+ BigInt(Math.max(0, Math.round(Number(command.argFloat ?? 0)))),
1391
1779
  1,
1392
1780
  );
1393
1781
  break;
@@ -1395,9 +1783,9 @@ export class SonareRtRealtimeEngineRuntime {
1395
1783
  this.module._sonare_rt_engine_set_metronome_enabled(
1396
1784
  this.engine,
1397
1785
  command.argInt ? 1 : 0,
1398
- 0.25,
1399
- 0.75,
1400
- 64,
1786
+ this.metronomeConfig.beatGain,
1787
+ this.metronomeConfig.accentGain,
1788
+ this.metronomeConfig.clickSamples,
1401
1789
  );
1402
1790
  break;
1403
1791
  case SonareEngineCommandType.SeekMarker:
@@ -1486,8 +1874,10 @@ export class SonareRealtimeEngineNode {
1486
1874
  readonly capabilities: SonareRealtimeEngineNodeCapabilities;
1487
1875
  readonly commandRing?: SonareEngineCommandRingBuffer;
1488
1876
  readonly telemetryRing?: SonareEngineTelemetryRingBuffer;
1877
+ readonly meterRing?: SonareMeterRingBuffer;
1489
1878
  readonly ready: Promise<void>;
1490
1879
  private telemetryReadIndex = 0;
1880
+ private meterReadIndex = 0;
1491
1881
  private telemetryListeners = new Set<(telemetry: SonareEngineTelemetryRecord) => void>();
1492
1882
  private meterListeners = new Set<(meter: SonareWorkletMeterSnapshot) => void>();
1493
1883
  private resolveReady!: () => void;
@@ -1499,11 +1889,13 @@ export class SonareRealtimeEngineNode {
1499
1889
  capabilities: SonareRealtimeEngineNodeCapabilities,
1500
1890
  commandRing?: SonareEngineCommandRingBuffer,
1501
1891
  telemetryRing?: SonareEngineTelemetryRingBuffer,
1892
+ meterRing?: SonareMeterRingBuffer,
1502
1893
  ) {
1503
1894
  this.node = node;
1504
1895
  this.capabilities = capabilities;
1505
1896
  this.commandRing = commandRing;
1506
1897
  this.telemetryRing = telemetryRing;
1898
+ this.meterRing = meterRing;
1507
1899
  this.ready = new Promise((resolve, reject) => {
1508
1900
  this.resolveReady = resolve;
1509
1901
  this.rejectReady = reject;
@@ -1574,6 +1966,14 @@ export class SonareRealtimeEngineNode {
1574
1966
  mode === 'sab'
1575
1967
  ? createSonareEngineTelemetryRingBuffer(options.telemetryRingCapacity ?? 128)
1576
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;
1577
1977
  const channelCount = Math.max(1, Math.floor(options.channelCount ?? 2));
1578
1978
  const processorOptions: SonareRealtimeEngineWorkletProcessorOptions = {
1579
1979
  runtimeTarget,
@@ -1586,6 +1986,8 @@ export class SonareRealtimeEngineNode {
1586
1986
  commandRingCapacity: commandRing?.capacity,
1587
1987
  telemetrySharedBuffer: telemetryRing?.sharedBuffer,
1588
1988
  telemetryRingCapacity: telemetryRing?.capacity,
1989
+ meterSharedBuffer: meterRing?.sharedBuffer,
1990
+ meterRingCapacity: meterRing?.capacity,
1589
1991
  };
1590
1992
  const factory =
1591
1993
  options.nodeFactory ??
@@ -1612,6 +2014,7 @@ export class SonareRealtimeEngineNode {
1612
2014
  },
1613
2015
  commandRing,
1614
2016
  telemetryRing,
2017
+ meterRing,
1615
2018
  );
1616
2019
  }
1617
2020
 
@@ -1662,6 +2065,21 @@ export class SonareRealtimeEngineNode {
1662
2065
  return read.telemetry;
1663
2066
  }
1664
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
+
1665
2083
  onTelemetry(callback: (telemetry: SonareEngineTelemetryRecord) => void): () => void {
1666
2084
  this.telemetryListeners.add(callback);
1667
2085
  return () => {
@@ -1797,6 +2215,15 @@ export class SonareEngine {
1797
2215
 
1798
2216
  setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
1799
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.
1800
2227
  return this.realtimeNode.sendCommand({
1801
2228
  type: SonareEngineCommandType.SetLoop,
1802
2229
  targetId: enabled ? 1 : 0,
@@ -1807,10 +2234,17 @@ export class SonareEngine {
1807
2234
  }
1808
2235
 
1809
2236
  setParam(nodeId: string, param: string | number, value: number): boolean {
1810
- void nodeId;
1811
- void param;
1812
- void value;
1813
- 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
+ });
1814
2248
  }
1815
2249
 
1816
2250
  scheduleParam(
@@ -1826,6 +2260,11 @@ export class SonareEngine {
1826
2260
  lane.sort((a, b) => a.ppq - b.ppq);
1827
2261
  this.automationLanes.set(paramId, lane);
1828
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 });
1829
2268
  }
1830
2269
 
1831
2270
  addAutomationPoint(
@@ -1849,7 +2288,16 @@ export class SonareEngine {
1849
2288
  void target;
1850
2289
  void solo;
1851
2290
  void mute;
1852
- 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
+ );
1853
2301
  }
1854
2302
 
1855
2303
  addClip(
@@ -1890,16 +2338,25 @@ export class SonareEngine {
1890
2338
  const inSample = this.ppqToApproxSample(inPpq);
1891
2339
  const outSample = this.ppqToApproxSample(outPpq);
1892
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).
1893
2346
  return this.realtimeNode.sendCommand({
1894
2347
  type: SonareEngineCommandType.Punch,
1895
2348
  sampleTime: -1,
1896
2349
  argInt: inSample,
1897
- argFloat: outPpq,
2350
+ argFloat: outSample,
1898
2351
  });
1899
2352
  }
1900
2353
 
1901
2354
  setMetronome(opts: EngineMetronomeConfig): void {
1902
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 });
1903
2360
  this.realtimeNode.sendCommand({
1904
2361
  type: SonareEngineCommandType.SetMetronome,
1905
2362
  sampleTime: -1,
@@ -1916,7 +2373,15 @@ export class SonareEngine {
1916
2373
 
1917
2374
  seekMarker(markerId: number): boolean {
1918
2375
  this.offlineEngine.seekMarker(markerId);
1919
- 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
+ });
1920
2385
  }
1921
2386
 
1922
2387
  async renderOffline(totalFrames: number): Promise<Float32Array[]> {
@@ -1940,6 +2405,10 @@ export class SonareEngine {
1940
2405
  return this.realtimeNode.pollTelemetry();
1941
2406
  }
1942
2407
 
2408
+ pollMeters(): SonareWorkletMeterSnapshot[] {
2409
+ return this.realtimeNode.pollMeters();
2410
+ }
2411
+
1943
2412
  destroy(): void {
1944
2413
  if (this.destroyed) {
1945
2414
  return;
@@ -1952,11 +2421,29 @@ export class SonareEngine {
1952
2421
  }
1953
2422
 
1954
2423
  private syncClips(): void {
1955
- 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 });
1956
2431
  }
1957
2432
 
1958
2433
  private syncMarkers(): void {
1959
- 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);
1960
2447
  }
1961
2448
 
1962
2449
  private resolveParamId(nodeId: string, param: string | number): number {
@@ -1990,6 +2477,203 @@ export class SonareEngine {
1990
2477
  }
1991
2478
  }
1992
2479
 
2480
+ export class SonareRealtimeVoiceChangerWorkletProcessor {
2481
+ private static warnedMonoOverflow = false;
2482
+ private static warnedInterleavedOverflow = false;
2483
+ private changer: RealtimeVoiceChanger;
2484
+ private readonly sampleRate: number;
2485
+ private readonly blockSize: number;
2486
+ private readonly channelCount: number;
2487
+ // WASM-heap typed-memory views, sized to the worst case (blockSize *
2488
+ // channelCount). Acquired on the main thread (constructor) so the
2489
+ // audio-thread process() never crosses an allocation boundary.
2490
+ private monoInput: Float32Array;
2491
+ private monoOutput: Float32Array;
2492
+ // Planar heap-backed views (one Float32Array per channel) used by the
2493
+ // multi-channel path. AudioWorklet inputs/outputs are already planar
2494
+ // Float32Arrays, so this avoids the per-sample interleave/deinterleave
2495
+ // passes that the older interleaved path needed.
2496
+ private planarChannels: Float32Array[];
2497
+ private destroyed = false;
2498
+
2499
+ constructor(options: SonareRealtimeVoiceChangerWorkletProcessorOptions = {}) {
2500
+ this.sampleRate = options.sampleRate ?? 48000;
2501
+ this.blockSize = options.blockSize ?? 128;
2502
+ this.channelCount = Math.max(1, Math.floor(options.channelCount ?? 1));
2503
+ this.changer = new RealtimeVoiceChanger(options.preset ?? 'neutral-monitor');
2504
+ this.changer.prepare(this.sampleRate, this.blockSize, this.channelCount);
2505
+ // Acquire WASM-heap views once, sized to the worst case. These are alive
2506
+ // for the lifetime of the changer; if the host requests more frames per
2507
+ // process() than blockSize, we clamp (see ensure*Capacity).
2508
+ this.monoInput = this.changer.getMonoInputBuffer(this.blockSize);
2509
+ this.monoOutput = this.changer.getMonoOutputBuffer(this.blockSize);
2510
+ this.planarChannels = [];
2511
+ if (this.channelCount > 1) {
2512
+ for (let ch = 0; ch < this.channelCount; ch++) {
2513
+ this.planarChannels.push(this.changer.getPlanarChannelBuffer(ch, this.blockSize));
2514
+ }
2515
+ }
2516
+ }
2517
+
2518
+ /**
2519
+ * Handles a control-plane message from the main thread. Runs on the
2520
+ * AudioWorklet global scope but OUTSIDE of `process()` (i.e. outside the
2521
+ * realtime audio callback), so it is safe to perform JSON parsing and
2522
+ * DSP coefficient recomputation here. `setConfig` MUST NOT be deferred
2523
+ * into `process()` because that would block the audio thread for longer
2524
+ * than one render quantum (e.g. 128 samples / 44.1 kHz = ~2.9 ms).
2525
+ */
2526
+ receiveMessage(message: SonareRealtimeVoiceChangerMessage): void {
2527
+ if (this.destroyed) {
2528
+ return;
2529
+ }
2530
+ if (message.type === 'setConfig') {
2531
+ // Apply synchronously on the message-handler thread. `setConfig` may
2532
+ // allocate and parse JSON internally; doing it here keeps `process()`
2533
+ // realtime-safe.
2534
+ this.changer.setConfig(message.preset);
2535
+ } else if (message.type === 'reset') {
2536
+ this.changer.reset();
2537
+ } else if (message.type === 'destroy') {
2538
+ this.destroy();
2539
+ }
2540
+ }
2541
+
2542
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
2543
+ const output = outputs[0];
2544
+ if (this.destroyed || !output || output.length === 0) {
2545
+ return !this.destroyed;
2546
+ }
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
+
2556
+ const input = inputs[0];
2557
+ const requestedFrames = output[0]?.length ?? 0;
2558
+ const requestedChannels = Math.min(this.channelCount, output.length);
2559
+ if (requestedFrames === 0 || requestedChannels === 0) {
2560
+ return true;
2561
+ }
2562
+
2563
+ if (requestedChannels === 1) {
2564
+ // Clamp to the pre-allocated capacity; warn (once) if the host violated
2565
+ // the contract. We never reallocate on the audio thread.
2566
+ const frames = this.ensureMonoCapacity(requestedFrames);
2567
+ const source = input?.[0];
2568
+ if (source) {
2569
+ this.monoInput.set(source.subarray(0, frames));
2570
+ } else {
2571
+ this.monoInput.fill(0, 0, frames);
2572
+ }
2573
+ this.changer.processMonoInto(
2574
+ this.monoInput.subarray(0, frames),
2575
+ this.monoOutput.subarray(0, frames),
2576
+ );
2577
+ output[0].set(this.monoOutput.subarray(0, frames));
2578
+ return true;
2579
+ }
2580
+
2581
+ const frames = this.ensureInterleavedCapacity(requestedFrames, requestedChannels);
2582
+ const channels = requestedChannels;
2583
+ // Planar zero-copy path: AudioWorklet's input[ch] is already a
2584
+ // Float32Array per channel, so we set() straight into the heap-backed
2585
+ // planar view and processPreparedPlanar runs in place.
2586
+ for (let ch = 0; ch < channels; ch++) {
2587
+ const src = input?.[ch];
2588
+ const dst = this.planarChannels[ch];
2589
+ if (!dst) {
2590
+ continue;
2591
+ }
2592
+ if (src) {
2593
+ dst.set(src.subarray(0, frames));
2594
+ } else {
2595
+ dst.fill(0, 0, frames);
2596
+ }
2597
+ }
2598
+ this.changer.processPreparedPlanar(frames);
2599
+ for (let ch = 0; ch < channels; ch++) {
2600
+ const src = this.planarChannels[ch];
2601
+ if (src) {
2602
+ output[ch].set(src.subarray(0, frames));
2603
+ }
2604
+ // No `for frame` inner loop needed; output[ch] is a Float32Array.
2605
+ }
2606
+ return true;
2607
+ }
2608
+
2609
+ destroy(): void {
2610
+ if (this.destroyed) {
2611
+ return;
2612
+ }
2613
+ this.destroyed = true;
2614
+ this.changer.delete();
2615
+ }
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
+
2631
+ /**
2632
+ * Returns the number of frames we can actually process given the
2633
+ * pre-allocated capacity. If the host requests more frames than the
2634
+ * worst-case block size declared at construction time, we clamp to the
2635
+ * available capacity and warn once — we MUST NOT reallocate on the
2636
+ * realtime audio thread.
2637
+ */
2638
+ private ensureMonoCapacity(frames: number): number {
2639
+ const capacity = this.monoInput.length;
2640
+ if (frames <= capacity) {
2641
+ return frames;
2642
+ }
2643
+ if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow) {
2644
+ SonareRealtimeVoiceChangerWorkletProcessor.warnedMonoOverflow = true;
2645
+ // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
2646
+ console.warn(
2647
+ `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames} mono frames ` +
2648
+ `exceeds pre-allocated capacity ${capacity}; clamping. ` +
2649
+ 'Increase blockSize at construction time to avoid this.',
2650
+ );
2651
+ }
2652
+ return capacity;
2653
+ }
2654
+
2655
+ /**
2656
+ * Same contract as ensureMonoCapacity but for the planar per-channel
2657
+ * scratch. Returns the number of frames that fit in the available capacity.
2658
+ */
2659
+ private ensureInterleavedCapacity(frames: number, channels: number): number {
2660
+ const capacity = this.planarChannels[0]?.length ?? 0;
2661
+ if (frames <= capacity) {
2662
+ return frames;
2663
+ }
2664
+ if (!SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow) {
2665
+ SonareRealtimeVoiceChangerWorkletProcessor.warnedInterleavedOverflow = true;
2666
+ // biome-ignore lint/suspicious/noConsole: realtime-safety diagnostic.
2667
+ console.warn(
2668
+ `SonareRealtimeVoiceChangerWorkletProcessor: requested ${frames}x${channels} ` +
2669
+ `planar frames exceeds pre-allocated capacity ${capacity}; clamping. ` +
2670
+ 'Increase blockSize or channelCount at construction time to avoid this.',
2671
+ );
2672
+ }
2673
+ return capacity;
2674
+ }
2675
+ }
2676
+
1993
2677
  export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'): void {
1994
2678
  const scope = globalThis as unknown as {
1995
2679
  AudioWorkletProcessor?: new () => object;
@@ -2029,6 +2713,47 @@ export function registerSonareWorkletProcessor(name = 'sonare-worklet-processor'
2029
2713
  scope.registerProcessor(name, RegisteredSonareWorkletProcessor);
2030
2714
  }
2031
2715
 
2716
+ export function registerSonareRealtimeVoiceChangerWorkletProcessor(
2717
+ name = 'sonare-realtime-voice-changer-processor',
2718
+ ): void {
2719
+ const scope = globalThis as unknown as {
2720
+ AudioWorkletProcessor?: new () => object;
2721
+ registerProcessor?: (processorName: string, processorCtor: unknown) => void;
2722
+ };
2723
+ if (!scope.AudioWorkletProcessor || !scope.registerProcessor) {
2724
+ throw new Error('AudioWorkletProcessor is not available in this context.');
2725
+ }
2726
+ const Base = scope.AudioWorkletProcessor;
2727
+ class RegisteredSonareRealtimeVoiceChangerWorkletProcessor extends Base {
2728
+ private bridge: SonareRealtimeVoiceChangerWorkletProcessor;
2729
+ readonly port?: WorkletPort;
2730
+
2731
+ constructor(options?: {
2732
+ processorOptions?: SonareRealtimeVoiceChangerWorkletProcessorOptions;
2733
+ }) {
2734
+ super();
2735
+ const port = this.port;
2736
+ this.bridge = new SonareRealtimeVoiceChangerWorkletProcessor(options?.processorOptions ?? {});
2737
+ const onMessage = (event: { data: unknown }) => {
2738
+ if (isRealtimeVoiceChangerMessage(event.data)) {
2739
+ this.bridge.receiveMessage(event.data);
2740
+ }
2741
+ };
2742
+ if (port?.addEventListener) {
2743
+ port.addEventListener('message', onMessage);
2744
+ port.start?.();
2745
+ } else if (port) {
2746
+ port.onmessage = onMessage;
2747
+ }
2748
+ }
2749
+
2750
+ process(inputs: WorkletInput, outputs: WorkletOutput): boolean {
2751
+ return this.bridge.process(inputs, outputs);
2752
+ }
2753
+ }
2754
+ scope.registerProcessor(name, RegisteredSonareRealtimeVoiceChangerWorkletProcessor);
2755
+ }
2756
+
2032
2757
  export function registerSonareRealtimeEngineWorkletProcessor(
2033
2758
  name = 'sonare-realtime-engine-processor',
2034
2759
  ): void {
@@ -2060,6 +2785,10 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2060
2785
  const onMessage = (event: { data: unknown }) => {
2061
2786
  if (isEngineCommandRecord(event.data)) {
2062
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);
2063
2792
  }
2064
2793
  };
2065
2794
  if (port?.addEventListener) {
@@ -2092,6 +2821,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2092
2821
  if (!options.rtModuleUrl) {
2093
2822
  throw new Error('rtModuleUrl is required for sonare-rt AudioWorklet runtime.');
2094
2823
  }
2824
+ const rtModuleUrl = options.rtModuleUrl;
2095
2825
  const memory = new WebAssembly.Memory({ initial: 1024, maximum: 1024, shared: true });
2096
2826
  const globalFactory = (
2097
2827
  globalThis as typeof globalThis & {
@@ -2104,7 +2834,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2104
2834
  ).SonareRtModuleFactory;
2105
2835
  const moduleFactory = globalFactory
2106
2836
  ? { default: globalFactory }
2107
- : ((await import(options.rtModuleUrl)) as {
2837
+ : ((await import(rtModuleUrl)) as {
2108
2838
  default: (options?: {
2109
2839
  wasmMemory?: WebAssembly.Memory;
2110
2840
  wasmBinary?: ArrayBuffer | Uint8Array;
@@ -2114,7 +2844,7 @@ export function registerSonareRealtimeEngineWorkletProcessor(
2114
2844
  const module = await moduleFactory.default({
2115
2845
  wasmMemory: memory,
2116
2846
  wasmBinary: options.rtWasmBinary,
2117
- locateFile: (path) => options.rtModuleUrl!.replace(/[^/]*$/, path),
2847
+ locateFile: (path) => rtModuleUrl.replace(/[^/]*$/, path),
2118
2848
  });
2119
2849
  this.rtBridge = new SonareRtRealtimeEngineRuntime({
2120
2850
  module,