@libraz/libsonare 1.8.0 → 1.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/README.md +2 -2
  2. package/dist/_effects_common.d.ts +2 -1
  3. package/dist/_effects_common.d.ts.map +1 -1
  4. package/dist/analysis.js +1 -0
  5. package/dist/analysis.js.map +1 -1
  6. package/dist/effects_mastering.d.ts +2 -2
  7. package/dist/effects_mastering.d.ts.map +1 -1
  8. package/dist/effects_note_ops.d.ts +55 -30
  9. package/dist/effects_note_ops.d.ts.map +1 -1
  10. package/dist/effects_timepitch.d.ts +17 -12
  11. package/dist/effects_timepitch.d.ts.map +1 -1
  12. package/dist/feature_decompose.d.ts +1 -1
  13. package/dist/feature_decompose.d.ts.map +1 -1
  14. package/dist/index.d.ts +3 -3
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +216 -60
  17. package/dist/index.js.map +1 -1
  18. package/dist/mastering_core.d.ts +27 -1
  19. package/dist/mastering_core.d.ts.map +1 -1
  20. package/dist/mixer.d.ts.map +1 -1
  21. package/dist/project_types.d.ts +7 -5
  22. package/dist/project_types.d.ts.map +1 -1
  23. package/dist/public_types_mastering.d.ts +19 -1
  24. package/dist/public_types_mastering.d.ts.map +1 -1
  25. package/dist/public_types_playback.d.ts +1 -0
  26. package/dist/public_types_playback.d.ts.map +1 -1
  27. package/dist/public_types_spectral.d.ts +5 -5
  28. package/dist/public_types_spectral.d.ts.map +1 -1
  29. package/dist/realtime_engine.d.ts +49 -6
  30. package/dist/realtime_engine.d.ts.map +1 -1
  31. package/dist/sonare-analysis.wasm +0 -0
  32. package/dist/sonare.d.ts +36 -0
  33. package/dist/sonare.js +1 -1
  34. package/dist/sonare.wasm +0 -0
  35. package/dist/streaming_processors.d.ts +5 -0
  36. package/dist/streaming_processors.d.ts.map +1 -1
  37. package/dist/worker.js.map +1 -1
  38. package/dist/worklet/engine-mixer-facade.d.ts +40 -4
  39. package/dist/worklet/engine-mixer-facade.d.ts.map +1 -1
  40. package/dist/worklet/engine-node.d.ts +2 -0
  41. package/dist/worklet/engine-node.d.ts.map +1 -1
  42. package/dist/worklet/engine-processor.d.ts +4 -0
  43. package/dist/worklet/engine-processor.d.ts.map +1 -1
  44. package/dist/worklet/engine-strips.d.ts +4 -2
  45. package/dist/worklet/engine-strips.d.ts.map +1 -1
  46. package/dist/worklet/engine-sync.d.ts +4 -2
  47. package/dist/worklet/engine-sync.d.ts.map +1 -1
  48. package/dist/worklet/engine.d.ts +27 -6
  49. package/dist/worklet/engine.d.ts.map +1 -1
  50. package/dist/worklet/guards.d.ts +7 -1
  51. package/dist/worklet/guards.d.ts.map +1 -1
  52. package/dist/worklet/messages.d.ts +4 -4
  53. package/dist/worklet/messages.d.ts.map +1 -1
  54. package/dist/worklet/mixer-processor.d.ts +2 -2
  55. package/dist/worklet/mixer-processor.d.ts.map +1 -1
  56. package/dist/worklet/protocol.d.ts +10 -2
  57. package/dist/worklet/protocol.d.ts.map +1 -1
  58. package/dist/worklet.js +585 -188
  59. package/dist/worklet.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/_effects_common.ts +36 -6
  62. package/src/effects_mastering.ts +4 -0
  63. package/src/effects_note_ops.ts +92 -44
  64. package/src/effects_timepitch.ts +42 -31
  65. package/src/feature_decompose.ts +2 -2
  66. package/src/index.ts +6 -0
  67. package/src/mastering_core.ts +95 -0
  68. package/src/mixer.ts +39 -19
  69. package/src/project_types.ts +7 -5
  70. package/src/public_types_mastering.ts +21 -0
  71. package/src/public_types_playback.ts +1 -0
  72. package/src/public_types_spectral.ts +5 -5
  73. package/src/realtime_engine.ts +71 -15
  74. package/src/sonare.js.d.ts +36 -0
  75. package/src/streaming_processors.ts +8 -0
  76. package/src/worklet/engine-mixer-facade.ts +420 -86
  77. package/src/worklet/engine-node.ts +22 -4
  78. package/src/worklet/engine-processor.ts +83 -33
  79. package/src/worklet/engine-strips.ts +5 -10
  80. package/src/worklet/engine-sync.ts +6 -1
  81. package/src/worklet/engine.ts +49 -37
  82. package/src/worklet/guards.ts +55 -0
  83. package/src/worklet/messages.ts +4 -2
  84. package/src/worklet/mixer-processor.ts +10 -3
  85. package/src/worklet/protocol.ts +29 -8
@@ -9,8 +9,10 @@ import {
9
9
  isEngineTransportResponseMessage,
10
10
  isExternalMidiBatchMessage,
11
11
  isMeterSnapshot,
12
+ isScopeSnapshot,
12
13
  requireChannelCount,
13
14
  requireIntegerOption,
15
+ resolveScopeBandCount,
14
16
  } from './guards';
15
17
  import type {
16
18
  SonareEngineCaptureRequestMessage,
@@ -72,7 +74,8 @@ function isValidCommandRecord(command: SonareEngineCommandRecord): boolean {
72
74
  !Number.isSafeInteger(type) ||
73
75
  type < SonareEngineCommandType.SetParam ||
74
76
  (type > SonareEngineCommandType.SeekMarker &&
75
- type !== SonareEngineCommandType.SetTrackMonitorMode)
77
+ type !== SonareEngineCommandType.SetTrackMonitorMode &&
78
+ type !== SonareEngineCommandType.ResetMasterLoudnessMeter)
76
79
  ) {
77
80
  return false;
78
81
  }
@@ -206,6 +209,8 @@ export class SonareRealtimeEngineNode {
206
209
  this.emitTelemetry(event.data);
207
210
  } else if (isMeterSnapshot(event.data)) {
208
211
  this.emitMeter(event.data);
212
+ } else if (isScopeSnapshot(event.data)) {
213
+ this.emitScope(event.data);
209
214
  } else if (isExternalMidiBatchMessage(event.data)) {
210
215
  this.emitMidiOut(event.data.events);
211
216
  } else if (isClipPageRequestMessage(event.data)) {
@@ -273,6 +278,13 @@ export class SonareRealtimeEngineNode {
273
278
  );
274
279
  }
275
280
 
281
+ const meterIntervalFrames = requireIntegerOption(
282
+ options.meterIntervalFrames,
283
+ 2048,
284
+ 'meterIntervalFrames',
285
+ 0,
286
+ );
287
+
276
288
  // Resolved before the transport branch, so a capacity the ring could not
277
289
  // hold is refused by name whichever transport the caller ends up with.
278
290
  const commandRingCapacity = requireIntegerOption(
@@ -302,7 +314,7 @@ export class SonareRealtimeEngineNode {
302
314
  'scopeRingCapacity',
303
315
  1,
304
316
  );
305
- const scopeBands = requireIntegerOption(options.scopeBands, 48, 'scopeBands', 1);
317
+ const scopeBands = resolveScopeBandCount(options.scopeBands);
306
318
  const clipPageRequestRingCapacity = requireIntegerOption(
307
319
  options.clipPageRequestRingCapacity,
308
320
  128,
@@ -352,6 +364,7 @@ export class SonareRealtimeEngineNode {
352
364
  sampleRate: options.sampleRate ?? context.sampleRate,
353
365
  blockSize,
354
366
  channelCount,
367
+ meterIntervalFrames,
355
368
  cueOutput,
356
369
  commandSharedBuffer: commandRing?.sharedBuffer,
357
370
  commandRingCapacity: commandRing?.capacity,
@@ -361,8 +374,8 @@ export class SonareRealtimeEngineNode {
361
374
  meterRingCapacity: meterRing?.capacity,
362
375
  scopeSharedBuffer: scopeRing?.sharedBuffer,
363
376
  scopeRingCapacity: scopeRing?.capacity,
364
- scopeBands: scopeRing?.bands,
365
- scopeIntervalFrames: scopeRing ? scopeIntervalFrames : undefined,
377
+ scopeBands: scopeIntervalFrames > 0 ? (scopeRing?.bands ?? scopeBands) : undefined,
378
+ scopeIntervalFrames: scopeIntervalFrames > 0 ? scopeIntervalFrames : undefined,
366
379
  clipPageRequestSharedBuffer: clipPageRequestRing?.sharedBuffer,
367
380
  clipPageRequestRingCapacity: clipPageRequestRing?.capacity,
368
381
  externalMidiSharedBuffer: externalMidiRing?.sharedBuffer,
@@ -416,6 +429,11 @@ export class SonareRealtimeEngineNode {
416
429
  return this.sendCommand({ type: SonareEngineCommandType.TransportPlay, sampleTime });
417
430
  }
418
431
 
432
+ /** Queues a reset of the master's integrated-loudness accumulator. */
433
+ resetMasterLoudnessMeter(sampleTime = -1): boolean {
434
+ return this.sendCommand({ type: SonareEngineCommandType.ResetMasterLoudnessMeter, sampleTime });
435
+ }
436
+
419
437
  stop(sampleTime = -1): boolean {
420
438
  return this.sendCommand({ type: SonareEngineCommandType.TransportStop, sampleTime });
421
439
  }
@@ -1,8 +1,13 @@
1
1
  import type { EngineClip } from '../index';
2
2
  import { RealtimeEngine } from '../index';
3
3
  import { copyPlanesToOutput, type WorkletInput, type WorkletOutput } from './audio_types';
4
- import { emptyStripJson } from './engine-mixer-facade';
5
- import { requireChannelCount, requireIntegerOption } from './guards';
4
+ import {
5
+ requireChannelCount,
6
+ requireIntegerOption,
7
+ resolveScopeBandCount,
8
+ SONARE_SCOPE_MAX_BANDS,
9
+ SONARE_SCOPE_MAX_POINTS,
10
+ } from './guards';
6
11
  import {
7
12
  DEFAULT_METRONOME_CONFIG,
8
13
  type ResolvedMetronomeConfig,
@@ -41,6 +46,7 @@ import {
41
46
  type SonareEngineTelemetryRecord,
42
47
  type SonareEngineTelemetryRingBuffer,
43
48
  SonareEngineTelemetryType,
49
+ type SonareWorkletScopeSnapshot,
44
50
  scopeRingFromSharedBuffer,
45
51
  telemetryFromEngine,
46
52
  writeInt64Words,
@@ -84,6 +90,9 @@ export class SonareRealtimeEngineWorkletProcessor {
84
90
  private transport?: WorkletTransport;
85
91
  private meterIntervalFrames: number;
86
92
  private lastMeterFrame = Number.NEGATIVE_INFINITY;
93
+ private scopeIntervalFrames: number;
94
+ private scopeEnabled: boolean;
95
+ private scopeBands: number;
87
96
  // Latest metronome gains/click length pushed via 'syncMetronome'. The
88
97
  // SetMetronome command only toggles enabled state; the config arrives here.
89
98
  private metronomeConfig: ResolvedMetronomeConfig = { ...DEFAULT_METRONOME_CONFIG };
@@ -146,6 +155,16 @@ export class SonareRealtimeEngineWorkletProcessor {
146
155
  options.scopeBands,
147
156
  )
148
157
  : undefined;
158
+ this.scopeIntervalFrames = requireIntegerOption(
159
+ options.scopeIntervalFrames,
160
+ this.scopeRing ? this.blockSize : 0,
161
+ 'scopeIntervalFrames',
162
+ 0,
163
+ );
164
+ this.scopeBands = resolveScopeBandCount(options.scopeBands);
165
+ this.scopeEnabled =
166
+ this.scopeIntervalFrames > 0 &&
167
+ (this.scopeRing !== undefined || typeof this.transport?.postMessage === 'function');
149
168
  this.clipPageRequestRing = options.clipPageRequestSharedBuffer
150
169
  ? clipPageRequestRingFromSharedBuffer(
151
170
  options.clipPageRequestSharedBuffer,
@@ -183,18 +202,12 @@ export class SonareRealtimeEngineWorkletProcessor {
183
202
  this.monitorBuffers[ch] = this.engine.getMonitorChannelBuffer(ch, this.blockSize);
184
203
  }
185
204
  }
186
- // Arm the engine's scope producer only when a scope ring was provided. The
187
- // band count follows the ring's record layout so writeScopeRing never
188
- // overruns its slot.
189
- if (this.scopeRing) {
190
- // Zero is the engine's own off switch for the scope tap, so the floor is 0.
191
- const interval = requireIntegerOption(
192
- options.scopeIntervalFrames,
193
- this.blockSize,
194
- 'scopeIntervalFrames',
195
- 0,
205
+ // Arm the native scope producer only when a SAB ring or postMessage sink exists.
206
+ if (this.scopeEnabled) {
207
+ this.engine.configureScopeTelemetry(
208
+ this.scopeIntervalFrames,
209
+ Math.min(this.scopeRing?.bands ?? this.scopeBands, SONARE_SCOPE_MAX_BANDS),
196
210
  );
197
- this.engine.configureScopeTelemetry(interval, this.scopeRing.bands);
198
211
  }
199
212
  }
200
213
 
@@ -496,37 +509,32 @@ export class SonareRealtimeEngineWorkletProcessor {
496
509
  // Complete earlier immediate edits in message order. Scheduled
497
510
  // commands remain queued for their render frame.
498
511
  this.engine.applyCommandsDueNowPreservingFuture();
499
- this.engine.settleInsertParameters();
500
512
  if (message.buses) {
501
513
  this.engine.setTrackBuses(message.buses);
502
514
  }
503
515
  this.engine.setTrackLanes(message.lanes);
504
- for (const target of message.forceInsertResets ?? []) {
505
- const emptyJson = emptyStripJson(target);
516
+ for (const strip of message.trackStrips ?? []) {
517
+ this.engine.setTrackStripJson(strip.trackId, strip.sceneJson);
518
+ }
519
+ for (const strip of message.busStrips ?? []) {
520
+ this.engine.setBusStripJson(strip.busId, strip.sceneJson);
521
+ }
522
+ if (message.masterStripJson) {
523
+ this.engine.setMasterStripJson(message.masterStripJson);
524
+ }
525
+ for (const target of message.insertBaseResets ?? []) {
506
526
  switch (target.kind) {
507
527
  case 'track':
508
528
  this.engine.clearTrackInsertParameterBases(target.trackId);
509
- this.engine.setTrackStripJson(target.trackId, emptyJson);
510
529
  break;
511
530
  case 'bus':
512
531
  this.engine.clearBusInsertParameterBases(target.busId);
513
- this.engine.setBusStripJson(target.busId, emptyJson);
514
532
  break;
515
533
  case 'master':
516
534
  this.engine.clearMasterInsertParameterBases();
517
- this.engine.setMasterStripJson(emptyJson);
518
535
  break;
519
536
  }
520
537
  }
521
- for (const strip of message.trackStrips ?? []) {
522
- this.engine.setTrackStripJson(strip.trackId, strip.sceneJson);
523
- }
524
- for (const strip of message.busStrips ?? []) {
525
- this.engine.setBusStripJson(strip.busId, strip.sceneJson);
526
- }
527
- if (message.masterStripJson) {
528
- this.engine.setMasterStripJson(message.masterStripJson);
529
- }
530
538
  for (const binding of message.laneSidechains ?? []) {
531
539
  this.engine.setLaneSidechain(binding.trackId, binding.insertIndex, binding.sourceTrackId);
532
540
  }
@@ -1056,6 +1064,9 @@ export class SonareRealtimeEngineWorkletProcessor {
1056
1064
  this.engine.setTrackMonitorMode(laneIndex, mode as 0 | 1 | 2, sampleTime);
1057
1065
  break;
1058
1066
  }
1067
+ case SonareEngineCommandType.ResetMasterLoudnessMeter:
1068
+ this.engine.resetMasterLoudnessMeter(sampleTime);
1069
+ break;
1059
1070
  default:
1060
1071
  this.publishTelemetryRecord({
1061
1072
  type: SonareEngineTelemetryType.Error,
@@ -1175,6 +1186,8 @@ export class SonareRealtimeEngineWorkletProcessor {
1175
1186
  for (let field = 0; field < 11; field++) {
1176
1187
  ring.records[offset + 3 + field] = this.engine.meterScratchValue(field);
1177
1188
  }
1189
+ ring.records[offset + 14] = this.engine.meterScratchInputPeakDbL();
1190
+ ring.records[offset + 15] = this.engine.meterScratchInputPeakDbR();
1178
1191
  Atomics.store(ring.header, 0, writeIndex + 1);
1179
1192
  }
1180
1193
 
@@ -1192,16 +1205,53 @@ export class SonareRealtimeEngineWorkletProcessor {
1192
1205
  // so header slot 3 is left at its initial 0.
1193
1206
 
1194
1207
  // Drains the engine's scope producer (FFT spectrum + goniometer points) into
1195
- // the lock-free SAB scope ring. No allocation on the render path: records are
1196
- // written field-by-field into the ring.
1208
+ // the lock-free SAB scope ring or the bounded postMessage fallback. The SAB
1209
+ // path remains allocation-free; postMessage materialises only records that
1210
+ // are ready to publish.
1197
1211
  private publishScope(): void {
1198
- const ring = this.scopeRing;
1199
- if (!ring) {
1212
+ if (!this.scopeEnabled) {
1200
1213
  return;
1201
1214
  }
1215
+ const ring = this.scopeRing;
1216
+ const transport = this.transport;
1202
1217
  for (let count = 0; count < 64 && this.engine.popScopeTelemetryToScratch(); count++) {
1203
- this.writeScopeScratch(ring);
1218
+ if (ring) {
1219
+ this.writeScopeScratch(ring);
1220
+ } else {
1221
+ this.postScopeScratch(transport);
1222
+ }
1223
+ }
1224
+ }
1225
+
1226
+ private postScopeScratch(transport: WorkletTransport | undefined): void {
1227
+ if (!transport?.postMessage) {
1228
+ return;
1229
+ }
1230
+ const bandCount = Math.min(
1231
+ SONARE_SCOPE_MAX_BANDS,
1232
+ Math.max(0, Math.trunc(Number(this.engine.scopeScratchBandCount()))),
1233
+ );
1234
+ const pointCount = Math.min(
1235
+ SONARE_SCOPE_MAX_POINTS,
1236
+ Math.max(0, Math.trunc(Number(this.engine.scopeScratchPointCount()))),
1237
+ );
1238
+ const bands = new Float32Array(bandCount);
1239
+ for (let i = 0; i < bandCount; i++) {
1240
+ bands[i] = this.engine.scopeScratchBand(i);
1241
+ }
1242
+ const points = new Float32Array(pointCount * 2);
1243
+ for (let i = 0; i < pointCount; i++) {
1244
+ points[2 * i] = this.engine.scopeScratchPointLeft(i);
1245
+ points[2 * i + 1] = this.engine.scopeScratchPointRight(i);
1204
1246
  }
1247
+ const scope: SonareWorkletScopeSnapshot = {
1248
+ type: 'scope',
1249
+ targetId: this.engine.scopeScratchTargetId(),
1250
+ frame: Number(this.engine.scopeScratchRenderFrame()),
1251
+ bands,
1252
+ points,
1253
+ };
1254
+ transport.postMessage(scope, [bands.buffer, points.buffer]);
1205
1255
  }
1206
1256
 
1207
1257
  private writeScopeScratch(ring: SharedScopeRingWriter): void {
@@ -2,12 +2,10 @@ import { panLawCode, panModeCode } from '../codes';
2
2
  import type { EqBand, PanLawInput, PanMode, RealtimeEngine } from '../index';
3
3
  import type { InsertParamOverrideMap } from './engine-mixer-facade';
4
4
  import {
5
- applyFullStripJson,
6
- clearInsertParamOverrides,
7
5
  emptyStripJson,
8
- hasInsertParamOverrides,
9
6
  type InsertParamOverride,
10
7
  insertParamOverrideKey,
8
+ replaceStripScene,
11
9
  type StripJsonTarget,
12
10
  } from './engine-mixer-facade';
13
11
  import type { SonareEngineInstrumentSyncMessage, SonareEngineSyncMessage } from './messages';
@@ -27,6 +25,7 @@ export interface EngineStripContext {
27
25
  readStripJson(target: StripJsonTarget): string | undefined;
28
26
  writeStripJson(target: StripJsonTarget, sceneJson: string): void;
29
27
  readonly insertParamOverrides: InsertParamOverrideMap;
28
+ clearInsertAutomationLanes(target: StripJsonTarget): void;
30
29
  }
31
30
 
32
31
  function trackIdFor(ctx: EngineStripContext, target: string | number): number {
@@ -92,15 +91,11 @@ export function setTrackStripJson(
92
91
  trackId: number,
93
92
  sceneJson: string,
94
93
  trackStripJson: Map<number, string>,
95
- ): { resetInserts: boolean } {
94
+ ): { insertBaseResets: StripJsonTarget[]; constructionOverrides: InsertParamOverride[] } {
96
95
  const target: StripJsonTarget = { kind: 'track', trackId };
97
- const resetInserts =
98
- (trackStripJson.has(trackId) && trackStripJson.get(trackId) !== sceneJson) ||
99
- hasInsertParamOverrides(ctx.insertParamOverrides, target);
100
- applyFullStripJson(ctx.offlineEngine, target, sceneJson, resetInserts);
101
- clearInsertParamOverrides(ctx.insertParamOverrides, target);
96
+ const result = replaceStripScene(ctx, target, trackStripJson.get(trackId), sceneJson);
102
97
  trackStripJson.set(trackId, sceneJson);
103
- return { resetInserts };
98
+ return result;
104
99
  }
105
100
 
106
101
  export function setTrackStripEqBand(
@@ -11,25 +11,30 @@ import type { SonareEngineSyncTempoMessage } from './messages';
11
11
  * Builds the ordered mixer-lane descriptors for a sync message.
12
12
  *
13
13
  * Each declared track id is paired with its current send list and output bus
14
- * (both omitted when absent), defensively copying the send entries.
14
+ * (both omitted when absent) and source channel layout, defensively copying the
15
+ * send entries.
15
16
  *
16
17
  * @param trackLaneIds Lane order (track ids) declared on the engine.
17
18
  * @param trackSends Per-track send lists.
18
19
  * @param trackOutputBus Per-track output bus routing.
20
+ * @param trackSourceChannelLayout Per-track source channel layouts.
19
21
  * @returns Lane descriptors in lane order.
20
22
  */
21
23
  export function buildMixerLanes(
22
24
  trackLaneIds: readonly number[],
23
25
  trackSends: ReadonlyMap<number, EngineTrackSend[]>,
24
26
  trackOutputBus: ReadonlyMap<number, number>,
27
+ trackSourceChannelLayout: ReadonlyMap<number, number>,
25
28
  ): EngineTrackLane[] {
26
29
  return trackLaneIds.map((trackId) => {
27
30
  const sends = trackSends.get(trackId);
28
31
  const outputBusId = trackOutputBus.get(trackId);
32
+ const sourceChannelLayout = trackSourceChannelLayout.get(trackId);
29
33
  return {
30
34
  trackId,
31
35
  ...(sends && sends.length > 0 ? { sends: sends.map((send) => ({ ...send })) } : {}),
32
36
  ...(outputBusId !== undefined ? { outputBusId } : {}),
37
+ ...(sourceChannelLayout !== undefined ? { sourceChannelLayout } : {}),
33
38
  };
34
39
  });
35
40
  }
@@ -102,6 +102,7 @@ export class SonareEngine {
102
102
  private readonly trackLaneIds: number[] = [];
103
103
  private readonly trackSends = new Map<number, EngineTrackSend[]>();
104
104
  private readonly trackOutputBus = new Map<number, number>();
105
+ private readonly trackSourceChannelLayout = new Map<number, number>();
105
106
  private readonly laneSidechains = new Map<
106
107
  string,
107
108
  { trackId: number; insertIndex: number; sourceTrackId: number }
@@ -242,6 +243,11 @@ export class SonareEngine {
242
243
  tempo.setTimeSignatureSegments(this.tempoContext, segments);
243
244
  }
244
245
 
246
+ /**
247
+ * A loop wrap, seek or stop sends note-offs plus CC64=0, CC121, CC123 and a
248
+ * centred pitch bend on every channel played since the last reset, so
249
+ * controller values set before a loop region are not restored at the wrap.
250
+ */
245
251
  setLoop(startPpq: number, endPpq: number, enabled = true): boolean {
246
252
  return tempo.setLoop(this.tempoContext, startPpq, endPpq, enabled);
247
253
  }
@@ -321,6 +327,15 @@ export class SonareEngine {
321
327
  * mixer lane first (like automationParamId) so the offline engine resolves the
322
328
  * same strip selector the realtime engine uses.
323
329
  *
330
+ * The returned id uses the track's current positional lane selector. When
331
+ * setTrackLanes successfully changes lane order or membership, the engine
332
+ * remaps already queued and published track automation by track id, but it
333
+ * cannot update a numeric id retained by the caller. Re-resolve every track
334
+ * insert id after such a topology change before passing it to
335
+ * setAutomationLane. Use setTrackStripInsertParamByName when the operation
336
+ * needs a stable track identity. Master and bus insert ids are separate and
337
+ * are not invalidated by track-lane changes.
338
+ *
324
339
  * @param target Track id (declares a mixer lane on first use).
325
340
  * @param insertIndex Index into the strip's combined insert sequence.
326
341
  * @param paramName Processor JSON-key parameter name.
@@ -454,14 +469,24 @@ export class SonareEngine {
454
469
  return sent;
455
470
  }
456
471
 
472
+ /** Resets the master's integrated loudness accumulator in both engine mirrors. */
473
+ resetMasterLoudnessMeter(renderFrame = -1): boolean {
474
+ this.offlineEngine.resetMasterLoudnessMeter(renderFrame);
475
+ return this.sendMirroredCommand({
476
+ type: SonareEngineCommandType.ResetMasterLoudnessMeter,
477
+ sampleTime: renderFrame,
478
+ });
479
+ }
480
+
457
481
  /**
458
482
  * Declares the mixer track lanes in an explicit order.
459
483
  *
460
- * Lane indices are append-only: once a track id occupies a lane, its index
461
- * stays fixed for the engine's lifetime. The given list must therefore start
462
- * with the already-declared lane ids in their current order and may only
463
- * append new track ids after them. Entries carrying `sends` replace that
464
- * track's send list; entries without `sends` leave existing sends untouched.
484
+ * A successful call may reorder existing track ids, remove track ids, or add
485
+ * new ones. Existing queued and published track automation follows the track
486
+ * id, while numeric track-insert automation ids retained by the caller do
487
+ * not; re-resolve those ids after every topology change. Entries carrying
488
+ * `sends` replace that track's send list; entries without `sends` leave
489
+ * existing sends untouched.
465
490
  *
466
491
  * @param lanes Track ids or lane descriptors in the desired lane order.
467
492
  */
@@ -528,19 +553,18 @@ export class SonareEngine {
528
553
  }
529
554
 
530
555
  setTrackStripJson(target: string | number, sceneJson: string): void {
556
+ // Parse before declaring a new lane so malformed text cannot declare one.
557
+ mixer.decodedInsertSignature(sceneJson, { kind: 'track', trackId: 0 });
531
558
  const laneIndex = this.ensureTrackLane(target);
532
559
  const trackId = this.trackLaneIds[laneIndex];
533
- const { resetInserts } = strips.setTrackStripJson(
560
+ const { insertBaseResets, constructionOverrides } = strips.setTrackStripJson(
534
561
  this.stripContext,
535
562
  trackId,
536
563
  sceneJson,
537
564
  this.trackStripJson,
538
565
  );
539
- if (resetInserts) {
540
- this.clearInsertAutomationLanes({ kind: 'track', trackId });
541
- }
542
566
  mixer.pruneStripSidechains(this.mixerContext, { kind: 'track', trackId }, sceneJson);
543
- this.syncMixer(resetInserts ? [{ kind: 'track', trackId }] : []);
567
+ this.syncMixer(insertBaseResets, constructionOverrides);
544
568
  }
545
569
 
546
570
  setTrackStripEqBand(target: string | number, bandIndex: number, band: EqBand | string): void {
@@ -625,18 +649,7 @@ export class SonareEngine {
625
649
  }
626
650
 
627
651
  setMasterStripJson(sceneJson: string): void {
628
- const target = { kind: 'master' } as const;
629
- const resetInserts =
630
- (this.masterStripJson !== undefined && this.masterStripJson !== sceneJson) ||
631
- mixer.hasInsertParamOverrides(this.insertParamOverrides, target);
632
- mixer.applyFullStripJson(this.offlineEngine, target, sceneJson, resetInserts);
633
- if (resetInserts) {
634
- this.clearInsertAutomationLanes(target);
635
- }
636
- mixer.pruneStripSidechains(this.mixerContext, target, sceneJson);
637
- mixer.clearInsertParamOverrides(this.insertParamOverrides, target);
638
- this.masterStripJson = sceneJson;
639
- this.syncMixer(resetInserts ? [target] : []);
652
+ mixer.setMasterStripJson(this.mixerContext, sceneJson);
640
653
  }
641
654
 
642
655
  setMasterStripEqBand(bandIndex: number, band: EqBand | string): void {
@@ -904,7 +917,10 @@ export class SonareEngine {
904
917
  /**
905
918
  * Route a track's MIDI to the external output (drained via {@link onMidiOut})
906
919
  * instead of an internal instrument, so the track plays an external device.
907
- * Pass `external=false` to restore internal-synth playback.
920
+ * Pass `external=false` to restore internal-synth playback. The change takes
921
+ * effect at the next processed block; switching a destination's route first
922
+ * releases its notes and resets its controllers through the old route, and
923
+ * drops its pending MIDI-FX events.
908
924
  */
909
925
  setMidiDestinationExternal(trackId: string | number, external: boolean): void {
910
926
  strips.setMidiDestinationExternal(this.stripContext, trackId, external);
@@ -1397,8 +1413,11 @@ export class SonareEngine {
1397
1413
  return mixer.mixerLanes(this.mixerContext);
1398
1414
  }
1399
1415
 
1400
- private syncMixer(forceInsertResets: mixer.StripJsonTarget[] = []): void {
1401
- mixer.syncMixer(this.mixerContext, false, forceInsertResets);
1416
+ private syncMixer(
1417
+ insertBaseResets: mixer.StripJsonTarget[] = [],
1418
+ oneShotInsertParamOverrides: mixer.InsertParamOverride[] = [],
1419
+ ): void {
1420
+ mixer.syncMixer(this.mixerContext, false, insertBaseResets, oneShotInsertParamOverrides);
1402
1421
  }
1403
1422
 
1404
1423
  private insertAutomationTargetKey(target: mixer.StripJsonTarget): string {
@@ -1494,6 +1513,7 @@ export class SonareEngine {
1494
1513
  trackLaneIds: this.trackLaneIds,
1495
1514
  trackSends: this.trackSends,
1496
1515
  trackOutputBus: this.trackOutputBus,
1516
+ trackSourceChannelLayout: this.trackSourceChannelLayout,
1497
1517
  laneSidechains: this.laneSidechains,
1498
1518
  busSidechains: this.busSidechains,
1499
1519
  masterSidechains: this.masterSidechains,
@@ -1506,7 +1526,8 @@ export class SonareEngine {
1506
1526
  ensureTrackLane: (target) => this.ensureTrackLane(target),
1507
1527
  ensureBus: (busId) => this.ensureBus(busId),
1508
1528
  mixerLanes: () => this.mixerLanes(),
1509
- syncMixer: (forceInsertResets) => this.syncMixer(forceInsertResets),
1529
+ syncMixer: (insertBaseResets, oneShotInsertParamOverrides) =>
1530
+ this.syncMixer(insertBaseResets, oneShotInsertParamOverrides),
1510
1531
  clearInsertAutomationLanes: (target, forgetResolvedIds) =>
1511
1532
  this.clearInsertAutomationLanes(target, forgetResolvedIds),
1512
1533
  sendSmoothedParam: (paramId, value) => this.sendSmoothedParam(paramId, value),
@@ -1532,6 +1553,7 @@ export class SonareEngine {
1532
1553
  writeStripJson: (target, sceneJson) =>
1533
1554
  mixer.cacheStripJson(this.mixerContext, target, sceneJson),
1534
1555
  insertParamOverrides: this.insertParamOverrides,
1556
+ clearInsertAutomationLanes: (target) => this.clearInsertAutomationLanes(target),
1535
1557
  };
1536
1558
  }
1537
1559
 
@@ -1683,17 +1705,7 @@ export class SonareEngine {
1683
1705
  }
1684
1706
 
1685
1707
  private ensureTrackLane(target: string | number): number {
1686
- const trackId = this.resolveTargetId(target);
1687
- if (!Number.isInteger(trackId) || trackId <= 0) {
1688
- throw new RangeError(`Invalid track id for mixer lane: ${String(target)}`);
1689
- }
1690
- const existing = this.trackLaneIds.indexOf(trackId);
1691
- if (existing >= 0) {
1692
- return existing;
1693
- }
1694
- this.trackLaneIds.push(trackId);
1695
- this.syncMixer();
1696
- return this.trackLaneIds.length - 1;
1708
+ return mixer.ensureTrackLane(this.mixerContext, target);
1697
1709
  }
1698
1710
 
1699
1711
  private ensureBus(busId: number): number {
@@ -13,9 +13,11 @@ import type {
13
13
  } from './messages';
14
14
  import {
15
15
  isRecord,
16
+ SONARE_SCOPE_DEFAULT_BANDS,
16
17
  type SonareEngineCommandRecord,
17
18
  type SonareEngineTelemetryRecord,
18
19
  type SonareWorkletMeterSnapshot,
20
+ type SonareWorkletScopeSnapshot,
19
21
  } from './protocol';
20
22
 
21
23
  export function isWorkletMessage(value: unknown): value is SonareWorkletMessage {
@@ -331,10 +333,55 @@ export function isMeterSnapshot(value: unknown): value is SonareWorkletMeterSnap
331
333
  typeof value.rmsDbL === 'number' &&
332
334
  typeof value.rmsDbR === 'number' &&
333
335
  typeof value.correlation === 'number' &&
336
+ Number.isFinite(value.inputPeakDbL) &&
337
+ Number.isFinite(value.inputPeakDbR) &&
334
338
  (typeof value.targetId === 'number' || value.targetId === undefined)
335
339
  );
336
340
  }
337
341
 
342
+ export const SONARE_SCOPE_MAX_BANDS = 64;
343
+ export const SONARE_SCOPE_MAX_POINTS = 32;
344
+
345
+ function isPlainFloat32Array(
346
+ value: unknown,
347
+ maxLength: number,
348
+ evenLength = false,
349
+ ): value is Float32Array {
350
+ if (
351
+ !(value instanceof Float32Array) ||
352
+ typeof ArrayBuffer === 'undefined' ||
353
+ !(value.buffer instanceof ArrayBuffer) ||
354
+ value.length > maxLength ||
355
+ (evenLength && value.length % 2 !== 0)
356
+ ) {
357
+ return false;
358
+ }
359
+ for (const sample of value) {
360
+ if (!Number.isFinite(sample)) {
361
+ return false;
362
+ }
363
+ }
364
+ return true;
365
+ }
366
+
367
+ /** Validates the bounded postMessage representation of a scope snapshot. */
368
+ export function isScopeSnapshot(value: unknown): value is SonareWorkletScopeSnapshot {
369
+ return (
370
+ isRecord(value) &&
371
+ value.type === 'scope' &&
372
+ typeof value.targetId === 'number' &&
373
+ Number.isSafeInteger(value.targetId) &&
374
+ value.targetId >= 0 &&
375
+ value.targetId <= 0xffff_ffff &&
376
+ typeof value.frame === 'number' &&
377
+ Number.isSafeInteger(value.frame) &&
378
+ value.frame >= 0 &&
379
+ isPlainFloat32Array(value.bands, SONARE_SCOPE_MAX_BANDS) &&
380
+ value.bands.length > 0 &&
381
+ isPlainFloat32Array(value.points, SONARE_SCOPE_MAX_POINTS * 2, true)
382
+ );
383
+ }
384
+
338
385
  /**
339
386
  * Resolves an integer option, refusing anything the field's own domain cannot
340
387
  * hold instead of rounding it into range.
@@ -356,6 +403,14 @@ export function requireIntegerOption(
356
403
  return resolved;
357
404
  }
358
405
 
406
+ /** Resolves scope bands and applies the native tap's fixed 64-band ceiling. */
407
+ export function resolveScopeBandCount(
408
+ value: number | undefined,
409
+ fallback = SONARE_SCOPE_DEFAULT_BANDS,
410
+ ): number {
411
+ return Math.min(requireIntegerOption(value, fallback, 'scopeBands', 1), SONARE_SCOPE_MAX_BANDS);
412
+ }
413
+
359
414
  /** As {@link requireIntegerOption}, for a field whose domain admits either sign. */
360
415
  export function requireInteger(value: number | undefined, fallback: number, name: string): number {
361
416
  const resolved = value ?? fallback;
@@ -21,6 +21,7 @@ import type {
21
21
  SonareEngineCommandRecord,
22
22
  SonareEngineTelemetryRecord,
23
23
  SonareWorkletMeterSnapshot,
24
+ SonareWorkletScopeSnapshot,
24
25
  SonareWorkletSpectrumSnapshot,
25
26
  } from './protocol';
26
27
 
@@ -288,6 +289,7 @@ export interface SonareEngineSyncErrorMessage {
288
289
 
289
290
  export type SonareWorkletTransportMessage =
290
291
  | SonareWorkletMeterSnapshot
292
+ | SonareWorkletScopeSnapshot
291
293
  | SonareWorkletSpectrumSnapshot
292
294
  | SonareWorkletExternalMidiMessage
293
295
  | SonareEngineClipPageRequestMessage
@@ -511,8 +513,8 @@ export interface SonareEngineSyncMixerMessage {
511
513
  masterStripJson?: string;
512
514
  /** Retained by-name insert values, applied after strip and sidechain replay. */
513
515
  insertParamOverrides?: SonareEngineSyncMixerInsertParamOverride[];
514
- /** Explicit full strip replacements that must reset retained insert state. */
515
- forceInsertResets?: Array<
516
+ /** Strips whose manual insert bases are cleared after the strip replay, before insertParamOverrides. */
517
+ insertBaseResets?: Array<
516
518
  { kind: 'track'; trackId: number } | { kind: 'bus'; busId: number } | { kind: 'master' }
517
519
  >;
518
520
  /** Lane insert sidechain bindings (replayed after lanes/strips). */