@libraz/libsonare 1.8.0 → 1.8.2

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 (138) hide show
  1. package/README.md +5 -5
  2. package/dist/_effects_common.d.ts +2 -1
  3. package/dist/_effects_common.d.ts.map +1 -1
  4. package/dist/analysis.js +3 -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 +11 -5
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2098 -155
  17. package/dist/index.js.map +1 -1
  18. package/dist/instrument_types.d.ts +27 -3
  19. package/dist/instrument_types.d.ts.map +1 -1
  20. package/dist/mastering_core.d.ts +30 -3
  21. package/dist/mastering_core.d.ts.map +1 -1
  22. package/dist/mixer.d.ts +2 -2
  23. package/dist/mixer.d.ts.map +1 -1
  24. package/dist/project.d.ts +5 -4
  25. package/dist/project.d.ts.map +1 -1
  26. package/dist/project_class.d.ts +57 -3
  27. package/dist/project_class.d.ts.map +1 -1
  28. package/dist/project_internal.d.ts +35 -1
  29. package/dist/project_internal.d.ts.map +1 -1
  30. package/dist/project_types.d.ts +17 -6
  31. package/dist/project_types.d.ts.map +1 -1
  32. package/dist/public_types.d.ts +1 -0
  33. package/dist/public_types.d.ts.map +1 -1
  34. package/dist/public_types_mastering.d.ts +19 -1
  35. package/dist/public_types_mastering.d.ts.map +1 -1
  36. package/dist/public_types_playback.d.ts +1 -0
  37. package/dist/public_types_playback.d.ts.map +1 -1
  38. package/dist/public_types_spectral.d.ts +5 -5
  39. package/dist/public_types_spectral.d.ts.map +1 -1
  40. package/dist/public_types_vocal_edit.d.ts +250 -0
  41. package/dist/public_types_vocal_edit.d.ts.map +1 -0
  42. package/dist/public_types_vocal_project.d.ts +63 -0
  43. package/dist/public_types_vocal_project.d.ts.map +1 -0
  44. package/dist/realtime_engine.d.ts +106 -16
  45. package/dist/realtime_engine.d.ts.map +1 -1
  46. package/dist/repair_dereverb.d.ts +1 -0
  47. package/dist/repair_dereverb.d.ts.map +1 -1
  48. package/dist/repair_noise.d.ts +1 -0
  49. package/dist/repair_noise.d.ts.map +1 -1
  50. package/dist/sonare-analysis.wasm +0 -0
  51. package/dist/sonare.d.ts +89 -0
  52. package/dist/sonare.js +1 -1
  53. package/dist/sonare.wasm +0 -0
  54. package/dist/streaming_processors.d.ts +5 -0
  55. package/dist/streaming_processors.d.ts.map +1 -1
  56. package/dist/vocal_edit.d.ts +131 -0
  57. package/dist/vocal_edit.d.ts.map +1 -0
  58. package/dist/vocal_edit_worker.d.ts +51 -0
  59. package/dist/vocal_edit_worker.d.ts.map +1 -0
  60. package/dist/vocal_edit_worker.js +1462 -0
  61. package/dist/vocal_edit_worker.js.map +1 -0
  62. package/dist/vocal_edit_worker_client.d.ts +118 -0
  63. package/dist/vocal_edit_worker_client.d.ts.map +1 -0
  64. package/dist/vocal_edit_worker_protocol.d.ts +139 -0
  65. package/dist/vocal_edit_worker_protocol.d.ts.map +1 -0
  66. package/dist/vocal_project.d.ts +17 -0
  67. package/dist/vocal_project.d.ts.map +1 -0
  68. package/dist/web_midi.d.ts +4 -0
  69. package/dist/web_midi.d.ts.map +1 -1
  70. package/dist/worker.js.map +1 -1
  71. package/dist/worklet/engine-mixer-facade.d.ts +42 -5
  72. package/dist/worklet/engine-mixer-facade.d.ts.map +1 -1
  73. package/dist/worklet/engine-node.d.ts +10 -0
  74. package/dist/worklet/engine-node.d.ts.map +1 -1
  75. package/dist/worklet/engine-processor.d.ts +6 -1
  76. package/dist/worklet/engine-processor.d.ts.map +1 -1
  77. package/dist/worklet/engine-register.d.ts.map +1 -1
  78. package/dist/worklet/engine-strips.d.ts +8 -4
  79. package/dist/worklet/engine-strips.d.ts.map +1 -1
  80. package/dist/worklet/engine-sync.d.ts +4 -2
  81. package/dist/worklet/engine-sync.d.ts.map +1 -1
  82. package/dist/worklet/engine.d.ts +42 -9
  83. package/dist/worklet/engine.d.ts.map +1 -1
  84. package/dist/worklet/guards.d.ts +10 -2
  85. package/dist/worklet/guards.d.ts.map +1 -1
  86. package/dist/worklet/messages.d.ts +54 -9
  87. package/dist/worklet/messages.d.ts.map +1 -1
  88. package/dist/worklet/mixer-processor.d.ts +2 -2
  89. package/dist/worklet/mixer-processor.d.ts.map +1 -1
  90. package/dist/worklet/protocol.d.ts +20 -2
  91. package/dist/worklet/protocol.d.ts.map +1 -1
  92. package/dist/worklet.d.ts +1 -1
  93. package/dist/worklet.d.ts.map +1 -1
  94. package/dist/worklet.js +808 -204
  95. package/dist/worklet.js.map +1 -1
  96. package/package.json +6 -1
  97. package/src/_effects_common.ts +36 -6
  98. package/src/effects_mastering.ts +4 -0
  99. package/src/effects_note_ops.ts +92 -44
  100. package/src/effects_timepitch.ts +42 -31
  101. package/src/feature_decompose.ts +2 -2
  102. package/src/index.ts +39 -0
  103. package/src/instrument_types.ts +29 -3
  104. package/src/mastering_core.ts +98 -2
  105. package/src/mixer.ts +41 -21
  106. package/src/project.ts +8 -1
  107. package/src/project_class.ts +136 -1
  108. package/src/project_internal.ts +90 -0
  109. package/src/project_types.ts +18 -6
  110. package/src/public_types.ts +1 -0
  111. package/src/public_types_mastering.ts +23 -0
  112. package/src/public_types_playback.ts +1 -0
  113. package/src/public_types_spectral.ts +5 -5
  114. package/src/public_types_vocal_edit.ts +289 -0
  115. package/src/public_types_vocal_project.ts +69 -0
  116. package/src/realtime_engine.ts +157 -24
  117. package/src/repair_dereverb.ts +1 -0
  118. package/src/repair_noise.ts +1 -0
  119. package/src/sonare.js.d.ts +89 -0
  120. package/src/streaming_processors.ts +8 -0
  121. package/src/vocal_edit.ts +885 -0
  122. package/src/vocal_edit_worker.ts +832 -0
  123. package/src/vocal_edit_worker_client.ts +812 -0
  124. package/src/vocal_edit_worker_protocol.ts +206 -0
  125. package/src/vocal_project.ts +327 -0
  126. package/src/web_midi.ts +100 -5
  127. package/src/worklet/engine-mixer-facade.ts +422 -87
  128. package/src/worklet/engine-node.ts +69 -4
  129. package/src/worklet/engine-processor.ts +129 -34
  130. package/src/worklet/engine-register.ts +3 -0
  131. package/src/worklet/engine-strips.ts +36 -14
  132. package/src/worklet/engine-sync.ts +6 -1
  133. package/src/worklet/engine.ts +76 -40
  134. package/src/worklet/guards.ts +81 -0
  135. package/src/worklet/messages.ts +63 -4
  136. package/src/worklet/mixer-processor.ts +10 -3
  137. package/src/worklet/protocol.ts +39 -8
  138. package/src/worklet.ts +2 -0
@@ -7,7 +7,7 @@ import type {
7
7
  SidechainSourceKind,
8
8
  } from '../index';
9
9
  import { normalizeTrackLanes } from './engine-offline';
10
- import { buildMixerLanes } from './engine-sync';
10
+ import { buildMixerLanes, resolveTargetId } from './engine-sync';
11
11
  import type { SonareEngineSyncMessage, SonareEngineSyncMixerInsertParamOverride } from './messages';
12
12
  import { ENGINE_MIXER_PARAM_FADER_DB, engineMixerBusTarget } from './protocol';
13
13
 
@@ -32,6 +32,7 @@ export interface EngineMixerContext {
32
32
  readonly trackLaneIds: number[];
33
33
  readonly trackSends: Map<number, EngineTrackSend[]>;
34
34
  readonly trackOutputBus: Map<number, number>;
35
+ readonly trackSourceChannelLayout: Map<number, number>;
35
36
  readonly laneSidechains: Map<
36
37
  string,
37
38
  { trackId: number; insertIndex: number; sourceTrackId: number }
@@ -54,7 +55,10 @@ export interface EngineMixerContext {
54
55
  ensureTrackLane(target: string | number): number;
55
56
  ensureBus(busId: number): number;
56
57
  mixerLanes(): EngineTrackLane[];
57
- syncMixer(forceInsertResets?: StripJsonTarget[]): void;
58
+ syncMixer(
59
+ insertBaseResets?: StripJsonTarget[],
60
+ oneShotInsertParamOverrides?: InsertParamOverride[],
61
+ ): void;
58
62
  sendSmoothedParam(paramId: number, value: number): boolean;
59
63
  getMasterStripJson(): string | undefined;
60
64
  cacheMasterStripJson(sceneJson: string): void;
@@ -66,6 +70,212 @@ export type StripJsonTarget =
66
70
  | { kind: 'bus'; busId: number }
67
71
  | { kind: 'master' };
68
72
 
73
+ type SceneObject = Record<string, unknown>;
74
+
75
+ function isSceneObject(value: unknown): value is SceneObject {
76
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
77
+ }
78
+
79
+ function hasOwn(object: SceneObject, key: string): boolean {
80
+ return Object.getOwnPropertyDescriptor(object, key) !== undefined;
81
+ }
82
+
83
+ /** Canonicalizes JSON objects without changing array order or string contents. */
84
+ function canonicalJson(value: unknown): string {
85
+ if (Array.isArray(value)) {
86
+ return `[${value.map((item) => canonicalJson(item)).join(',')}]`;
87
+ }
88
+ if (value !== null && typeof value === 'object') {
89
+ const object = value as SceneObject;
90
+ return `{${Object.keys(object)
91
+ .sort()
92
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(object[key])}`)
93
+ .join(',')}}`;
94
+ }
95
+ return JSON.stringify(value);
96
+ }
97
+
98
+ function aliasedValue(
99
+ object: SceneObject,
100
+ primary: string,
101
+ legacy: string,
102
+ ): { present: boolean; value: unknown } {
103
+ if (hasOwn(object, primary)) {
104
+ return { present: true, value: object[primary] };
105
+ }
106
+ if (hasOwn(object, legacy)) {
107
+ return { present: true, value: object[legacy] };
108
+ }
109
+ return { present: false, value: undefined };
110
+ }
111
+
112
+ function insertSignature(insert: unknown): string {
113
+ const object = insert !== null && typeof insert === 'object' ? (insert as SceneObject) : {};
114
+ const slot = object.slot === undefined ? 'pre' : object.slot;
115
+ const processor = aliasedValue(object, 'processor', 'processor_name');
116
+ const params = aliasedValue(object, 'params', 'params_json');
117
+ const sidechain = aliasedValue(object, 'sidechainKey', 'sidechain_key');
118
+ const processorValue = typeof processor.value === 'string' ? processor.value : '';
119
+ const sidechainValue = typeof sidechain.value === 'string' ? sidechain.value : '';
120
+ let paramsValue: unknown;
121
+ // A missing params bag and an empty legacy blob decode alike; "{}" is distinct.
122
+ if (!params.present) {
123
+ paramsValue = ['absent'];
124
+ } else if (typeof params.value === 'string') {
125
+ // Canonicalize legacy text like object params; invalid text stays byte-exact.
126
+ if (params.value.length === 0) {
127
+ paramsValue = ['absent'];
128
+ } else {
129
+ try {
130
+ paramsValue = ['params', canonicalJson(JSON.parse(params.value))];
131
+ } catch {
132
+ paramsValue = ['params-invalid', params.value];
133
+ }
134
+ }
135
+ } else {
136
+ paramsValue = ['params', canonicalJson(params.value)];
137
+ }
138
+ return JSON.stringify([slot, processorValue, paramsValue, sidechainValue]);
139
+ }
140
+
141
+ /**
142
+ * Returns the insert identity used to decide whether native state can stay
143
+ * alive across a strip-scene update. Scalar strip fields are intentionally
144
+ * excluded. Track and master inserts use the native pre-then-post indexing;
145
+ * buses retain their scene order.
146
+ */
147
+ export function decodedInsertSignature(sceneJson: string, target: StripJsonTarget): string {
148
+ const scene = JSON.parse(sceneJson) as {
149
+ strips?: unknown[];
150
+ buses?: unknown[];
151
+ };
152
+ const entries = target.kind === 'bus' ? scene.buses : scene.strips;
153
+ const strip = Array.isArray(entries) ? entries.find(isSceneObject) : undefined;
154
+ const object = isSceneObject(strip) ? strip : {};
155
+ const inserts = Array.isArray(object.inserts) ? object.inserts.filter(isSceneObject) : [];
156
+ const ordered =
157
+ target.kind === 'bus'
158
+ ? inserts
159
+ : [
160
+ ...inserts.filter((insert) => insert.slot !== 'post'),
161
+ ...inserts.filter((insert) => insert.slot === 'post'),
162
+ ];
163
+ return JSON.stringify(ordered.map(insertSignature));
164
+ }
165
+
166
+ function constructionValue(
167
+ engine: RealtimeEngine,
168
+ target: StripJsonTarget,
169
+ override: InsertParamOverride,
170
+ ): number | undefined {
171
+ let parameterId = -1;
172
+ switch (target.kind) {
173
+ case 'track':
174
+ parameterId = engine.resolveTrackInsertAutomationId(
175
+ target.trackId,
176
+ override.insertIndex,
177
+ override.paramName,
178
+ );
179
+ break;
180
+ case 'bus':
181
+ parameterId = engine.resolveBusInsertAutomationId(
182
+ target.busId,
183
+ override.insertIndex,
184
+ override.paramName,
185
+ );
186
+ break;
187
+ case 'master':
188
+ parameterId = engine.resolveMasterInsertAutomationId(
189
+ override.insertIndex,
190
+ override.paramName,
191
+ );
192
+ break;
193
+ }
194
+ if (parameterId < 0) {
195
+ return undefined;
196
+ }
197
+ return engine.insertParameterConstructedValue(parameterId);
198
+ }
199
+
200
+ /** Builds one-shot restores for manual insert edits before their bookkeeping is forgotten. */
201
+ export function constructionInsertOverrides(
202
+ engine: RealtimeEngine,
203
+ target: StripJsonTarget,
204
+ overrides: InsertParamOverrideMap,
205
+ ): InsertParamOverride[] {
206
+ const restored: InsertParamOverride[] = [];
207
+ for (const override of overrides.values()) {
208
+ if (!sameStripTarget(override.target, target)) {
209
+ continue;
210
+ }
211
+ const value = constructionValue(engine, target, override);
212
+ if (value !== undefined) {
213
+ restored.push({ ...override, value });
214
+ }
215
+ }
216
+ return restored;
217
+ }
218
+
219
+ function applyInsertParamOverride(engine: RealtimeEngine, override: InsertParamOverride): void {
220
+ switch (override.target.kind) {
221
+ case 'track':
222
+ engine.restoreTrackStripInsertParamByName(
223
+ override.target.trackId,
224
+ override.insertIndex,
225
+ override.paramName,
226
+ override.value,
227
+ );
228
+ break;
229
+ case 'bus':
230
+ engine.restoreBusStripInsertParamByName(
231
+ override.target.busId,
232
+ override.insertIndex,
233
+ override.paramName,
234
+ override.value,
235
+ );
236
+ break;
237
+ case 'master':
238
+ engine.restoreMasterStripInsertParamByName(
239
+ override.insertIndex,
240
+ override.paramName,
241
+ override.value,
242
+ );
243
+ break;
244
+ }
245
+ }
246
+
247
+ /** Applies construction restores to the control-side mirror before posting them. */
248
+ export function applyConstructionInsertOverrides(
249
+ engine: RealtimeEngine,
250
+ overrides: readonly InsertParamOverride[],
251
+ ): void {
252
+ for (const override of overrides) {
253
+ applyInsertParamOverride(engine, override);
254
+ }
255
+ }
256
+
257
+ /** Clears the native manual parameter bases for one strip after a replacement. */
258
+ export function clearInsertParameterBases(engine: RealtimeEngine, target: StripJsonTarget): void {
259
+ switch (target.kind) {
260
+ case 'track':
261
+ engine.clearTrackInsertParameterBases(target.trackId);
262
+ break;
263
+ case 'bus':
264
+ engine.clearBusInsertParameterBases(target.busId);
265
+ break;
266
+ case 'master':
267
+ engine.clearMasterInsertParameterBases();
268
+ break;
269
+ }
270
+ }
271
+
272
+ interface MixerRoutingDraft {
273
+ trackLaneIds: number[];
274
+ trackSends: Map<number, EngineTrackSend[]>;
275
+ trackOutputBus: Map<number, number>;
276
+ trackSourceChannelLayout: Map<number, number>;
277
+ }
278
+
69
279
  /** An empty construction scene used only within an explicit strip replacement. */
70
280
  export function emptyStripJson(target: StripJsonTarget): string {
71
281
  switch (target.kind) {
@@ -123,12 +333,11 @@ export function hasInsertParamOverrides(
123
333
  return false;
124
334
  }
125
335
 
126
- /** Apply the replacement first to validate it, then force fresh insert state. */
336
+ /** Apply and validate one candidate scene without rebuilding it through an empty scene. */
127
337
  export function applyFullStripJson(
128
338
  engine: RealtimeEngine,
129
339
  target: StripJsonTarget,
130
340
  sceneJson: string,
131
- resetInserts: boolean,
132
341
  ): void {
133
342
  const apply = (json: string): void => {
134
343
  switch (target.kind) {
@@ -145,21 +354,6 @@ export function applyFullStripJson(
145
354
  };
146
355
  engine.applyCommandsDueNowPreservingFuture();
147
356
  apply(sceneJson);
148
- if (resetInserts) {
149
- switch (target.kind) {
150
- case 'track':
151
- engine.clearTrackInsertParameterBases(target.trackId);
152
- break;
153
- case 'bus':
154
- engine.clearBusInsertParameterBases(target.busId);
155
- break;
156
- case 'master':
157
- engine.clearMasterInsertParameterBases();
158
- break;
159
- }
160
- apply(emptyStripJson(target));
161
- apply(sceneJson);
162
- }
163
357
  }
164
358
 
165
359
  /** Builds a collision-safe key for one target/insert/parameter tuple. */
@@ -264,53 +458,80 @@ export function cacheStripJson(
264
458
 
265
459
  /** Builds the engine's track-lane descriptors from the current routing stores. */
266
460
  export function mixerLanes(ctx: EngineMixerContext): EngineTrackLane[] {
267
- return buildMixerLanes(ctx.trackLaneIds, ctx.trackSends, ctx.trackOutputBus);
461
+ return buildMixerLanes(
462
+ ctx.trackLaneIds,
463
+ ctx.trackSends,
464
+ ctx.trackOutputBus,
465
+ ctx.trackSourceChannelLayout,
466
+ );
268
467
  }
269
468
 
270
- /**
271
- * Mirrors the current mixer routing into the offline engine and posts the full
272
- * mixer-sync message (lanes, buses, strip JSON, sidechains) to the worklet.
273
- */
274
- export function syncMixer(
469
+ function cloneMixerRouting(ctx: EngineMixerContext): MixerRoutingDraft {
470
+ return {
471
+ trackLaneIds: [...ctx.trackLaneIds],
472
+ trackSends: new Map(
473
+ Array.from(ctx.trackSends, ([trackId, sends]) => [
474
+ trackId,
475
+ sends.map((send) => ({ ...send })),
476
+ ]),
477
+ ),
478
+ trackOutputBus: new Map(ctx.trackOutputBus),
479
+ trackSourceChannelLayout: new Map(ctx.trackSourceChannelLayout),
480
+ };
481
+ }
482
+
483
+ function commitMixerRouting(ctx: EngineMixerContext, draft: MixerRoutingDraft): void {
484
+ ctx.trackLaneIds.splice(0, ctx.trackLaneIds.length, ...draft.trackLaneIds);
485
+ ctx.trackSends.clear();
486
+ for (const [trackId, sends] of draft.trackSends) {
487
+ ctx.trackSends.set(
488
+ trackId,
489
+ sends.map((send) => ({ ...send })),
490
+ );
491
+ }
492
+ ctx.trackOutputBus.clear();
493
+ for (const [trackId, busId] of draft.trackOutputBus) {
494
+ ctx.trackOutputBus.set(trackId, busId);
495
+ }
496
+ ctx.trackSourceChannelLayout.clear();
497
+ for (const [trackId, layout] of draft.trackSourceChannelLayout) {
498
+ ctx.trackSourceChannelLayout.set(trackId, layout);
499
+ }
500
+ }
501
+
502
+ function buildDraftMixerLanes(draft: MixerRoutingDraft): EngineTrackLane[] {
503
+ return buildMixerLanes(
504
+ draft.trackLaneIds,
505
+ draft.trackSends,
506
+ draft.trackOutputBus,
507
+ draft.trackSourceChannelLayout,
508
+ );
509
+ }
510
+
511
+ function applyMixerRouting(
275
512
  ctx: EngineMixerContext,
276
- busesAlreadyApplied = false,
277
- forceInsertResets: StripJsonTarget[] = [],
278
- ): void {
279
- const lanes = mixerLanes(ctx);
513
+ draft: MixerRoutingDraft,
514
+ busesAlreadyApplied: boolean,
515
+ ): { lanes: EngineTrackLane[]; buses: EngineBus[] } {
516
+ const lanes = buildDraftMixerLanes(draft);
280
517
  const buses = ctx.buses.map((bus) => ({ ...bus }));
281
518
  if (!busesAlreadyApplied) {
282
519
  ctx.offlineEngine.setTrackBuses(buses);
283
520
  }
284
521
  if (lanes.length > 0) {
285
- ctx.offlineEngine.settleInsertParameters();
522
+ // Not settled: that would snap unrelated insert ramps on a scalar edit.
286
523
  ctx.offlineEngine.setTrackLanes(lanes);
287
524
  }
288
- if (forceInsertResets.length > 0) {
289
- // The temporary empty strip drops native sidechain bindings. Restore the
290
- // offline mirror in the same order the worklet restores them below.
291
- for (const binding of ctx.laneSidechains.values()) {
292
- ctx.offlineEngine.setLaneSidechain(
293
- binding.trackId,
294
- binding.insertIndex,
295
- binding.sourceTrackId,
296
- );
297
- }
298
- for (const binding of ctx.busSidechains.values()) {
299
- ctx.offlineEngine.setBusSidechain(
300
- binding.busId,
301
- binding.insertIndex,
302
- binding.sourceKind,
303
- binding.sourceId,
304
- );
305
- }
306
- for (const binding of ctx.masterSidechains.values()) {
307
- ctx.offlineEngine.setMasterSidechain(
308
- binding.insertIndex,
309
- binding.sourceKind,
310
- binding.sourceId,
311
- );
312
- }
313
- }
525
+ return { lanes, buses };
526
+ }
527
+
528
+ function postMixerSync(
529
+ ctx: EngineMixerContext,
530
+ lanes: EngineTrackLane[],
531
+ buses: EngineBus[],
532
+ insertBaseResets: StripJsonTarget[],
533
+ oneShotInsertParamOverrides: InsertParamOverride[],
534
+ ): void {
314
535
  replayInsertParamOverrides(ctx, lanes, buses);
315
536
  const trackStrips = Array.from(ctx.trackStripJson, ([trackId, sceneJson]) => ({
316
537
  trackId,
@@ -320,10 +541,10 @@ export function syncMixer(
320
541
  busId,
321
542
  sceneJson,
322
543
  }));
323
- const insertParamOverrides = Array.from(
324
- ctx.insertParamOverrides.values(),
325
- flattenInsertParamOverride,
326
- );
544
+ const insertParamOverrides = [
545
+ ...Array.from(ctx.insertParamOverrides.values(), flattenInsertParamOverride),
546
+ ...oneShotInsertParamOverrides.map(flattenInsertParamOverride),
547
+ ];
327
548
  ctx.postSync({
328
549
  type: 'syncMixer',
329
550
  lanes,
@@ -335,10 +556,41 @@ export function syncMixer(
335
556
  busSidechains: Array.from(ctx.busSidechains.values()),
336
557
  masterSidechains: Array.from(ctx.masterSidechains.values()),
337
558
  ...(insertParamOverrides.length > 0 ? { insertParamOverrides } : {}),
338
- ...(forceInsertResets.length > 0 ? { forceInsertResets } : {}),
559
+ ...(insertBaseResets.length > 0 ? { insertBaseResets } : {}),
339
560
  });
340
561
  }
341
562
 
563
+ function syncMixerDraft(
564
+ ctx: EngineMixerContext,
565
+ draft: MixerRoutingDraft,
566
+ busesAlreadyApplied = false,
567
+ insertBaseResets: StripJsonTarget[] = [],
568
+ oneShotInsertParamOverrides: InsertParamOverride[] = [],
569
+ ): void {
570
+ const { lanes, buses } = applyMixerRouting(ctx, draft, busesAlreadyApplied);
571
+ commitMixerRouting(ctx, draft);
572
+ postMixerSync(ctx, lanes, buses, insertBaseResets, oneShotInsertParamOverrides);
573
+ }
574
+
575
+ /**
576
+ * Mirrors the current mixer routing into the offline engine and posts the full
577
+ * mixer-sync message (lanes, buses, strip JSON, sidechains) to the worklet.
578
+ */
579
+ export function syncMixer(
580
+ ctx: EngineMixerContext,
581
+ busesAlreadyApplied = false,
582
+ insertBaseResets: StripJsonTarget[] = [],
583
+ oneShotInsertParamOverrides: InsertParamOverride[] = [],
584
+ ): void {
585
+ syncMixerDraft(
586
+ ctx,
587
+ cloneMixerRouting(ctx),
588
+ busesAlreadyApplied,
589
+ insertBaseResets,
590
+ oneShotInsertParamOverrides,
591
+ );
592
+ }
593
+
342
594
  /**
343
595
  * Reapplies retained by-name values after native topology setters clear their
344
596
  * smoother slots. This runs once per mixer sync, never once per knob tick.
@@ -397,6 +649,7 @@ export function replayInsertParamOverrides(
397
649
  * with the already-declared lane ids in their current order and may only
398
650
  * append new track ids after them. Entries carrying `sends` replace that
399
651
  * track's send list; entries without `sends` leave existing sends untouched.
652
+ * The same omission rule preserves an existing source channel layout.
400
653
  *
401
654
  * @param lanes Track ids or lane descriptors in the desired lane order.
402
655
  */
@@ -404,24 +657,51 @@ export function setTrackLanes(
404
657
  ctx: EngineMixerContext,
405
658
  lanes: ReadonlyArray<number | EngineTrackLane>,
406
659
  ): void {
407
- const { entries, ids } = normalizeTrackLanes(ctx.trackLaneIds, lanes);
660
+ const draft = cloneMixerRouting(ctx);
661
+ const { entries, ids } = normalizeTrackLanes(draft.trackLaneIds, lanes);
408
662
  for (const entry of entries) {
409
663
  if (entry.sends) {
410
- ctx.trackSends.set(
664
+ draft.trackSends.set(
411
665
  entry.trackId,
412
666
  entry.sends.map((send) => ({ ...send })),
413
667
  );
414
668
  }
415
669
  if (entry.outputBusId !== undefined) {
416
670
  if (entry.outputBusId === 0) {
417
- ctx.trackOutputBus.delete(entry.trackId);
671
+ draft.trackOutputBus.delete(entry.trackId);
418
672
  } else {
419
- ctx.trackOutputBus.set(entry.trackId, entry.outputBusId);
673
+ draft.trackOutputBus.set(entry.trackId, entry.outputBusId);
420
674
  }
421
675
  }
676
+ if (entry.sourceChannelLayout !== undefined) {
677
+ draft.trackSourceChannelLayout.set(entry.trackId, entry.sourceChannelLayout);
678
+ }
422
679
  }
423
- ctx.trackLaneIds.splice(0, ctx.trackLaneIds.length, ...ids);
424
- ctx.syncMixer();
680
+ draft.trackLaneIds = ids;
681
+ syncMixerDraft(ctx, draft, true);
682
+ }
683
+
684
+ function ensureTrackLaneInDraft(draft: MixerRoutingDraft, target: string | number): number {
685
+ const trackId = resolveTargetId(target);
686
+ if (!Number.isInteger(trackId) || trackId <= 0) {
687
+ throw new RangeError(`Invalid track id for mixer lane: ${String(target)}`);
688
+ }
689
+ const existing = draft.trackLaneIds.indexOf(trackId);
690
+ if (existing >= 0) {
691
+ return existing;
692
+ }
693
+ draft.trackLaneIds.push(trackId);
694
+ return draft.trackLaneIds.length - 1;
695
+ }
696
+
697
+ /** Ensures a lane exists, committing it only after native routing accepts it. */
698
+ export function ensureTrackLane(ctx: EngineMixerContext, target: string | number): number {
699
+ const draft = cloneMixerRouting(ctx);
700
+ const laneIndex = ensureTrackLaneInDraft(draft, target);
701
+ if (draft.trackLaneIds.length !== ctx.trackLaneIds.length) {
702
+ syncMixerDraft(ctx, draft, true);
703
+ }
704
+ return laneIndex;
425
705
  }
426
706
 
427
707
  /**
@@ -433,20 +713,22 @@ export function setTrackOutputBus(
433
713
  target: string | number,
434
714
  busId: number,
435
715
  ): void {
436
- const laneIndex = ctx.ensureTrackLane(target);
437
- const trackId = ctx.trackLaneIds[laneIndex];
716
+ const draft = cloneMixerRouting(ctx);
717
+ const laneIndex = ensureTrackLaneInDraft(draft, target);
718
+ const trackId = draft.trackLaneIds[laneIndex];
438
719
  if (busId === 0) {
439
- ctx.trackOutputBus.delete(trackId);
720
+ draft.trackOutputBus.delete(trackId);
440
721
  } else {
441
- ctx.trackOutputBus.set(trackId, busId);
722
+ draft.trackOutputBus.set(trackId, busId);
442
723
  }
443
- ctx.syncMixer();
724
+ syncMixerDraft(ctx, draft, true);
444
725
  }
445
726
 
446
727
  /**
447
728
  * Keys one insert of a lane strip from another lane's post-strip pre-fader
448
729
  * audio (ducking/sidechainRouter inserts). sourceTarget null removes the
449
- * binding.
730
+ * binding. The binding is cached for resync only once the engine has accepted
731
+ * it, so a refused (self-keyed or cyclic) binding throws and is never replayed.
450
732
  */
451
733
  export function setLaneSidechain(
452
734
  ctx: EngineMixerContext,
@@ -552,13 +834,14 @@ export function setSends(
552
834
  target: string | number,
553
835
  sends: EngineTrackSend[],
554
836
  ): void {
555
- const laneIndex = ctx.ensureTrackLane(target);
556
- const trackId = ctx.trackLaneIds[laneIndex];
557
- ctx.trackSends.set(
837
+ const draft = cloneMixerRouting(ctx);
838
+ const laneIndex = ensureTrackLaneInDraft(draft, target);
839
+ const trackId = draft.trackLaneIds[laneIndex];
840
+ draft.trackSends.set(
558
841
  trackId,
559
842
  sends.map((send) => ({ ...send })),
560
843
  );
561
- ctx.syncMixer();
844
+ syncMixerDraft(ctx, draft, true);
562
845
  }
563
846
 
564
847
  export function setTrackBuses(ctx: EngineMixerContext, buses: EngineBus[]): void {
@@ -603,18 +886,70 @@ export function setBusGain(ctx: EngineMixerContext, busId: number, db: number):
603
886
  return ctx.sendSmoothedParam(engineMixerBusTarget(busIndex, ENGINE_MIXER_PARAM_FADER_DB), db);
604
887
  }
605
888
 
889
+ /** Control-side state a strip-scene replacement reads and updates. */
890
+ export interface StripSceneContext {
891
+ readonly offlineEngine: RealtimeEngine;
892
+ readonly insertParamOverrides: InsertParamOverrideMap;
893
+ clearInsertAutomationLanes(target: StripJsonTarget): void;
894
+ }
895
+
896
+ /**
897
+ * Applies a strip scene to the offline mirror and returns what the realtime
898
+ * sync must repeat: the targets whose manual insert bases it clears and the
899
+ * construction values it restores. A changed insert chain forgets manual edits;
900
+ * an equal chain with manual edits restores each edited parameter's
901
+ * construction value. Native state of equal inserts is kept either way.
902
+ */
903
+ export function replaceStripScene(
904
+ ctx: StripSceneContext,
905
+ target: StripJsonTarget,
906
+ previousJson: string | undefined,
907
+ sceneJson: string,
908
+ ): { insertBaseResets: StripJsonTarget[]; constructionOverrides: InsertParamOverride[] } {
909
+ const chainChanged =
910
+ previousJson !== undefined &&
911
+ decodedInsertSignature(previousJson, target) !== decodedInsertSignature(sceneJson, target);
912
+ const hasOverrides = hasInsertParamOverrides(ctx.insertParamOverrides, target);
913
+ applyFullStripJson(ctx.offlineEngine, target, sceneJson);
914
+ if (!chainChanged && !hasOverrides) {
915
+ return { insertBaseResets: [], constructionOverrides: [] };
916
+ }
917
+ const constructionOverrides = chainChanged
918
+ ? []
919
+ : constructionInsertOverrides(ctx.offlineEngine, target, ctx.insertParamOverrides);
920
+ ctx.clearInsertAutomationLanes(target);
921
+ clearInsertParameterBases(ctx.offlineEngine, target);
922
+ applyConstructionInsertOverrides(ctx.offlineEngine, constructionOverrides);
923
+ clearInsertParamOverrides(ctx.insertParamOverrides, target);
924
+ return { insertBaseResets: [target], constructionOverrides };
925
+ }
926
+
606
927
  export function setBusStripJson(ctx: EngineMixerContext, busId: number, sceneJson: string): void {
607
- ctx.ensureBus(busId);
608
928
  const target: StripJsonTarget = { kind: 'bus', busId };
609
- const resetInserts =
610
- (ctx.busStripJson.has(busId) && ctx.busStripJson.get(busId) !== sceneJson) ||
611
- hasInsertParamOverrides(ctx.insertParamOverrides, target);
612
- applyFullStripJson(ctx.offlineEngine, target, sceneJson, resetInserts);
613
- if (resetInserts) {
614
- ctx.clearInsertAutomationLanes(target);
615
- }
929
+ // Parse before declaring a new bus so malformed text cannot declare one.
930
+ decodedInsertSignature(sceneJson, target);
931
+ ctx.ensureBus(busId);
932
+ const { insertBaseResets, constructionOverrides } = replaceStripScene(
933
+ ctx,
934
+ target,
935
+ ctx.busStripJson.get(busId),
936
+ sceneJson,
937
+ );
616
938
  pruneStripSidechains(ctx, target, sceneJson);
617
939
  ctx.busStripJson.set(busId, sceneJson);
618
- clearInsertParamOverrides(ctx.insertParamOverrides, target);
619
- ctx.syncMixer(resetInserts ? [target] : []);
940
+ ctx.syncMixer(insertBaseResets, constructionOverrides);
941
+ }
942
+
943
+ /** Applies a master strip scene while retaining native state for equal inserts. */
944
+ export function setMasterStripJson(ctx: EngineMixerContext, sceneJson: string): void {
945
+ const target: StripJsonTarget = { kind: 'master' };
946
+ const { insertBaseResets, constructionOverrides } = replaceStripScene(
947
+ ctx,
948
+ target,
949
+ ctx.getMasterStripJson(),
950
+ sceneJson,
951
+ );
952
+ pruneStripSidechains(ctx, target, sceneJson);
953
+ ctx.cacheMasterStripJson(sceneJson);
954
+ ctx.syncMixer(insertBaseResets, constructionOverrides);
620
955
  }