@opendaw/studio-core 0.0.131 → 0.0.133

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.
@@ -1 +1 @@
1
- {"version":3,"file":"AudioContentModifier.d.ts","sourceRoot":"","sources":["../../../src/project/audio/AudioContentModifier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,IAAI,EAAiD,MAAM,kBAAkB,CAAA;AAShG,OAAO,EAAC,sBAAsB,EAAwB,MAAM,0BAA0B,CAAA;AAKtF,yBAAiB,oBAAoB,CAAC;IAC3B,MAAM,cAAc,GAAU,UAAU,aAAa,CAAC,sBAAsB,CAAC,KAAG,OAAO,CAAC,IAAI,CAelG,CAAA;IAEM,MAAM,cAAc,GAAU,UAAU,aAAa,CAAC,sBAAsB,CAAC,KAAG,OAAO,CAAC,IAAI,CA6BlG,CAAA;IAEM,MAAM,aAAa,GAAU,UAAU,aAAa,CAAC,sBAAsB,CAAC,KAAG,OAAO,CAAC,IAAI,CA+CjG,CAAA;CA0BJ"}
1
+ {"version":3,"file":"AudioContentModifier.d.ts","sourceRoot":"","sources":["../../../src/project/audio/AudioContentModifier.ts"],"names":[],"mappings":"AAAA,OAAO,EAAY,IAAI,EAA4D,MAAM,kBAAkB,CAAA;AAS3G,OAAO,EAAC,sBAAsB,EAA8C,MAAM,0BAA0B,CAAA;AAK5G,yBAAiB,oBAAoB,CAAC;IAC3B,MAAM,cAAc,GAAU,UAAU,aAAa,CAAC,sBAAsB,CAAC,KAAG,OAAO,CAAC,IAAI,CAqBlG,CAAA;IAEM,MAAM,cAAc,GAAU,UAAU,aAAa,CAAC,sBAAsB,CAAC,KAAG,OAAO,CAAC,IAAI,CA6BlG,CAAA;IAEM,MAAM,aAAa,GAAU,UAAU,aAAa,CAAC,sBAAsB,CAAC,KAAG,OAAO,CAAC,IAAI,CA+CjG,CAAA;CAoDJ"}
@@ -1,4 +1,4 @@
1
- import { EmptyExec, isDefined, isInstanceOf, RuntimeNotifier, UUID } from "@opendaw/lib-std";
1
+ import { EmptyExec, isDefined, isInstanceOf, isNotNull, RuntimeNotifier, UUID } from "@opendaw/lib-std";
2
2
  import { TimeBase } from "@opendaw/lib-dsp";
3
3
  import { AudioPitchStretchBox, AudioTimeStretchBox, TransientMarkerBox, WarpMarkerBox } from "@opendaw/studio-boxes";
4
4
  import { AudioRegionBoxAdapter } from "@opendaw/studio-adapters";
@@ -15,6 +15,12 @@ export var AudioContentModifier;
15
15
  return () => audioAdapters.forEach((adapter) => {
16
16
  const audibleDuration = adapter.optWarpMarkers
17
17
  .mapOr(warpMarkers => warpMarkers.last()?.seconds ?? 0, 0);
18
+ const loopOffsetSeconds = isInstanceOf(adapter, AudioRegionBoxAdapter)
19
+ ? adapter.optWarpMarkers.mapOr(warpMarkers => warpPositionToSeconds(warpMarkers, adapter.loopOffset), 0)
20
+ : 0;
21
+ if (loopOffsetSeconds !== 0) {
22
+ adapter.box.waveformOffset.setValue(adapter.waveformOffset.getValue() + loopOffsetSeconds);
23
+ }
18
24
  adapter.box.playMode.defer();
19
25
  adapter.asPlayModeTimeStretch.ifSome(({ box }) => {
20
26
  if (box.pointerHub.filter(Pointers.AudioPlayMode).length === 0) {
@@ -57,7 +63,8 @@ export var AudioContentModifier;
57
63
  }
58
64
  }
59
65
  else {
60
- AudioContentHelpers.addDefaultWarpMarkers(boxGraph, pitchStretch, adapter.duration, adapter.box.duration.getValue());
66
+ const { ppqn, seconds } = sampleExtent(adapter);
67
+ AudioContentHelpers.addDefaultWarpMarkers(boxGraph, pitchStretch, ppqn, seconds);
61
68
  }
62
69
  switchTimeBaseToMusical(adapter);
63
70
  });
@@ -101,7 +108,8 @@ export var AudioContentModifier;
101
108
  }
102
109
  }
103
110
  else {
104
- AudioContentHelpers.addDefaultWarpMarkers(boxGraph, timeStretch, adapter.duration, adapter.box.duration.getValue());
111
+ const { ppqn, seconds } = sampleExtent(adapter);
112
+ AudioContentHelpers.addDefaultWarpMarkers(boxGraph, timeStretch, ppqn, seconds);
105
113
  }
106
114
  if (isDefined(transients) && adapter.file.transients.length() === 0) {
107
115
  const markersField = adapter.file.box.transientMarkers;
@@ -113,6 +121,38 @@ export var AudioContentModifier;
113
121
  switchTimeBaseToMusical(adapter);
114
122
  });
115
123
  };
124
+ const warpPositionToSeconds = (warpMarkers, position) => {
125
+ const length = warpMarkers.length();
126
+ if (length === 0) {
127
+ return 0;
128
+ }
129
+ const first = warpMarkers.first();
130
+ const last = warpMarkers.last();
131
+ if (!isNotNull(first) || !isNotNull(last)) {
132
+ return 0;
133
+ }
134
+ if (position <= first.position) {
135
+ return first.seconds;
136
+ }
137
+ if (position >= last.position) {
138
+ return last.seconds;
139
+ }
140
+ for (let i = 0; i < length - 1; i++) {
141
+ const left = warpMarkers.optAt(i);
142
+ const right = warpMarkers.optAt(i + 1);
143
+ if (isNotNull(left) && isNotNull(right) && position >= left.position && position < right.position) {
144
+ const alpha = (position - left.position) / (right.position - left.position);
145
+ return left.seconds + alpha * (right.seconds - left.seconds);
146
+ }
147
+ }
148
+ return last.seconds;
149
+ };
150
+ const sampleExtent = (adapter) => {
151
+ if (isInstanceOf(adapter, AudioRegionBoxAdapter)) {
152
+ return { ppqn: adapter.loopDuration, seconds: adapter.box.loopDuration.getValue() };
153
+ }
154
+ return { ppqn: adapter.duration, seconds: adapter.box.duration.getValue() };
155
+ };
116
156
  const switchTimeBaseToSeconds = ({ box, timeBase }, audibleDuration) => {
117
157
  if (timeBase === TimeBase.Seconds) {
118
158
  return;
@@ -1 +1 @@
1
- {"version":3,"file":"DevicesClipboardHandler.d.ts","sourceRoot":"","sources":["../../../../src/ui/clipboard/types/DevicesClipboardHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAGH,OAAO,EAKP,MAAM,EAEN,QAAQ,EAGX,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAM,QAAQ,EAAC,MAAM,kBAAkB,CAAA;AAG9C,OAAO,EAEH,WAAW,EACX,gBAAgB,EAEhB,UAAU,EAGV,iBAAiB,EAGpB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAC,cAAc,EAAE,gBAAgB,EAAC,MAAM,qBAAqB,CAAA;AAGpE,KAAK,gBAAgB,GAAG,cAAc,CAAC,SAAS,CAAC,CAAA;AAajD,yBAAiB,gBAAgB,CAAC;IAC9B,KAAY,OAAO,GAAG;QAClB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;QACtC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;QACzB,QAAQ,CAAC,SAAS,EAAE,iBAAiB,CAAC,gBAAgB,CAAC,CAAA;QACvD,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;QAC3B,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;QACjC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAA;KACjD,CAAA;IAyBM,MAAM,aAAa,GAAI,oEAOG,OAAO,KAAG,gBAAgB,CAAC,gBAAgB,CAwN3E,CAAA;CACJ"}
1
+ {"version":3,"file":"DevicesClipboardHandler.d.ts","sourceRoot":"","sources":["../../../../src/ui/clipboard/types/DevicesClipboardHandler.ts"],"names":[],"mappings":"AAAA,OAAO,EAGH,OAAO,EAKP,MAAM,EAEN,QAAQ,EAGX,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAM,QAAQ,EAAC,MAAM,kBAAkB,CAAA;AAG9C,OAAO,EAEH,WAAW,EACX,gBAAgB,EAEhB,UAAU,EAGV,iBAAiB,EAGpB,MAAM,0BAA0B,CAAA;AACjC,OAAO,EAAC,cAAc,EAAE,gBAAgB,EAAC,MAAM,qBAAqB,CAAA;AAGpE,KAAK,gBAAgB,GAAG,cAAc,CAAC,SAAS,CAAC,CAAA;AAajD,yBAAiB,gBAAgB,CAAC;IAC9B,KAAY,OAAO,GAAG;QAClB,QAAQ,CAAC,UAAU,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;QACtC,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAA;QACzB,QAAQ,CAAC,SAAS,EAAE,iBAAiB,CAAC,gBAAgB,CAAC,CAAA;QACvD,QAAQ,CAAC,QAAQ,EAAE,QAAQ,CAAA;QAC3B,QAAQ,CAAC,WAAW,EAAE,WAAW,CAAA;QACjC,QAAQ,CAAC,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC,CAAA;KACjD,CAAA;IAyBM,MAAM,aAAa,GAAI,oEAOG,OAAO,KAAG,gBAAgB,CAAC,gBAAgB,CA4N3E,CAAA;CACJ"}
@@ -71,9 +71,10 @@ export var DevicesClipboard;
71
71
  .map(pointer => pointer.box);
72
72
  const mandatoryDeps = Array.from(boxGraph.dependenciesOf(box, {
73
73
  alwaysFollowMandatory: true,
74
+ stopAtResources: true,
74
75
  excludeBox: (dep) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
75
76
  || dep.name === RootBox.ClassName
76
- }).boxes).filter(dep => !isDefined(dep.resource));
77
+ }).boxes).filter(dep => dep.resource !== "preserved");
77
78
  const preserved = [box, ...ownedChildren].flatMap(root => Array.from(boxGraph.dependenciesOf(root, {
78
79
  alwaysFollowMandatory: true,
79
80
  excludeBox: (dep) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
@@ -94,6 +95,7 @@ export var DevicesClipboard;
94
95
  trackContent.push(regionPointer.box);
95
96
  const regionDeps = Array.from(boxGraph.dependenciesOf(regionPointer.box, {
96
97
  alwaysFollowMandatory: true,
98
+ stopAtResources: true,
97
99
  excludeBox: (dep) => dep.ephemeral
98
100
  || isInstanceOf(dep, TrackBox)
99
101
  || DeviceBoxUtils.isDeviceBox(dep)
@@ -212,7 +214,7 @@ export var DevicesClipboard;
212
214
  if (pointer.pointerType === Pointers.AudioEffectHost) {
213
215
  return Option.wrap(host.audioEffectsField.address);
214
216
  }
215
- if (pointer.pointerType === Pointers.TrackCollection && replaceInstrument) {
217
+ if (pointer.pointerType === Pointers.TrackCollection) {
216
218
  return Option.wrap(host.audioUnitBoxAdapter().tracksField.address);
217
219
  }
218
220
  if (pointer.pointerType === Pointers.Automation && replaceInstrument) {
@@ -227,8 +229,18 @@ export var DevicesClipboard;
227
229
  }
228
230
  return Option.None;
229
231
  },
230
- excludeBox: box => !replaceInstrument
231
- && (DeviceBoxUtils.isInstrumentDeviceBox(box) || isInstanceOf(box, TrackBox))
232
+ excludeBox: box => {
233
+ if (replaceInstrument) {
234
+ return false;
235
+ }
236
+ if (DeviceBoxUtils.isInstrumentDeviceBox(box)) {
237
+ return true;
238
+ }
239
+ if (isInstanceOf(box, TrackBox)) {
240
+ return metadata.hasInstrument;
241
+ }
242
+ return false;
243
+ }
232
244
  });
233
245
  const deviceBoxes = boxes.filter(box => DeviceBoxUtils.isDeviceBox(box));
234
246
  const newMidiEffects = deviceBoxes
@@ -239,14 +251,12 @@ export var DevicesClipboard;
239
251
  .sort((a, b) => a.index.getValue() - b.index.getValue());
240
252
  newMidiEffects.forEach((box, idx) => box.index.setValue(midiInsertIndex + idx));
241
253
  newAudioEffects.forEach((box, idx) => box.index.setValue(audioInsertIndex + idx));
242
- if (replaceInstrument) {
243
- const tracksField = host.audioUnitBoxAdapter().tracksField;
244
- const allTracks = tracksField.pointerHub.filter(Pointers.TrackCollection)
245
- .filter(pointer => isInstanceOf(pointer.box, TrackBox))
246
- .map(pointer => pointer.box)
247
- .sort((trackA, trackB) => trackA.index.getValue() - trackB.index.getValue());
248
- allTracks.forEach((track, idx) => track.index.setValue(idx));
249
- }
254
+ const tracksField = host.audioUnitBoxAdapter().tracksField;
255
+ const allTracks = tracksField.pointerHub.filter(Pointers.TrackCollection)
256
+ .filter(pointer => isInstanceOf(pointer.box, TrackBox))
257
+ .map(pointer => pointer.box)
258
+ .sort((trackA, trackB) => trackA.index.getValue() - trackB.index.getValue());
259
+ allTracks.forEach((track, idx) => track.index.setValue(idx));
250
260
  selection.select(...deviceBoxes.map(box => boxAdapters.adapterFor(box, Devices.isAny)));
251
261
  });
252
262
  }
@@ -195,9 +195,10 @@ describe("DevicesClipboardHandler", () => {
195
195
  .map(pointer => pointer.box);
196
196
  const mandatoryDeps = Array.from(boxGraph.dependenciesOf(deviceBox, {
197
197
  alwaysFollowMandatory: true,
198
+ stopAtResources: true,
198
199
  excludeBox: (dep) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
199
200
  || dep.name === RootBox.ClassName
200
- }).boxes).filter(dep => !isDefined(dep.resource));
201
+ }).boxes).filter(dep => dep.resource !== "preserved");
201
202
  const preserved = [deviceBox, ...ownedChildren].flatMap(root => Array.from(boxGraph.dependenciesOf(root, {
202
203
  alwaysFollowMandatory: true,
203
204
  excludeBox: (dep) => dep.ephemeral || DeviceBoxUtils.isDeviceBox(dep)
@@ -215,6 +216,7 @@ describe("DevicesClipboardHandler", () => {
215
216
  trackContent.push(regionPointer.box);
216
217
  const regionDeps = Array.from(boxGraph.dependenciesOf(regionPointer.box, {
217
218
  alwaysFollowMandatory: true,
219
+ stopAtResources: true,
218
220
  excludeBox: (dep) => dep.ephemeral
219
221
  || isInstanceOf(dep, TrackBox)
220
222
  || DeviceBoxUtils.isDeviceBox(dep)
@@ -232,7 +234,7 @@ describe("DevicesClipboardHandler", () => {
232
234
  return true;
233
235
  });
234
236
  };
235
- const makePasteMapper = (targetAudioUnit, replaceInstrument) => ({
237
+ const makePasteMapper = (targetAudioUnit, replaceInstrument, hasInstrument = true) => ({
236
238
  mapPointer: (pointer) => {
237
239
  if (pointer.pointerType === Pointers.InstrumentHost && replaceInstrument) {
238
240
  return Option.wrap(targetAudioUnit.input.address);
@@ -243,7 +245,7 @@ describe("DevicesClipboardHandler", () => {
243
245
  if (pointer.pointerType === Pointers.MIDIEffectHost) {
244
246
  return Option.wrap(targetAudioUnit.midiEffects.address);
245
247
  }
246
- if (pointer.pointerType === Pointers.TrackCollection && replaceInstrument) {
248
+ if (pointer.pointerType === Pointers.TrackCollection) {
247
249
  return Option.wrap(targetAudioUnit.tracks.address);
248
250
  }
249
251
  if (pointer.pointerType === Pointers.Automation && replaceInstrument) {
@@ -251,7 +253,18 @@ describe("DevicesClipboardHandler", () => {
251
253
  }
252
254
  return Option.None;
253
255
  },
254
- excludeBox: (box) => !replaceInstrument && (DeviceBoxUtils.isInstrumentDeviceBox(box) || isInstanceOf(box, TrackBox))
256
+ excludeBox: (box) => {
257
+ if (replaceInstrument) {
258
+ return false;
259
+ }
260
+ if (DeviceBoxUtils.isInstrumentDeviceBox(box)) {
261
+ return true;
262
+ }
263
+ if (isInstanceOf(box, TrackBox)) {
264
+ return hasInstrument;
265
+ }
266
+ return false;
267
+ }
255
268
  });
256
269
  // ─────────────────────────────────────────────────────────
257
270
  // Audio effect paste
@@ -284,6 +297,113 @@ describe("DevicesClipboardHandler", () => {
284
297
  });
285
298
  });
286
299
  // ─────────────────────────────────────────────────────────
300
+ // Audio effect with automation: copy scope + paste round-trip
301
+ // ─────────────────────────────────────────────────────────
302
+ describe("audio effect with automation", () => {
303
+ it("includes ValueEventCollectionBox when copying effect with automation events", () => {
304
+ const audioUnit = createAudioUnit(source);
305
+ const effect = addAudioEffect(source, audioUnit, "Comp", 0);
306
+ const autoTrack = addAutomationTrack(source, audioUnit, effect.threshold, 0);
307
+ addValueRegion(source, autoTrack, 0, 960);
308
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
309
+ expect(deps.filter(box => isInstanceOf(box, TrackBox)).length).toBe(1);
310
+ expect(deps.filter(box => isInstanceOf(box, ValueRegionBox)).length).toBe(1);
311
+ expect(deps.filter(box => isInstanceOf(box, ValueEventCollectionBox)).length).toBe(1);
312
+ });
313
+ it("includes mirror regions on different tracks of the same device", () => {
314
+ const audioUnit = createAudioUnit(source);
315
+ const effect = addAudioEffect(source, audioUnit, "Comp", 0);
316
+ const trackA = addAutomationTrack(source, audioUnit, effect.threshold, 0);
317
+ const trackB = addAutomationTrack(source, audioUnit, effect.ratio, 1);
318
+ const regionA = addValueRegion(source, trackA, 0, 960);
319
+ const sharedEvents = regionA.events.targetVertex.unwrap().box;
320
+ let regionB;
321
+ source.boxGraph.beginTransaction();
322
+ regionB = ValueRegionBox.create(source.boxGraph, UUID.generate(), box => {
323
+ box.regions.refer(trackB.regions);
324
+ box.events.refer(sharedEvents.owners);
325
+ box.position.setValue(0);
326
+ box.duration.setValue(960);
327
+ });
328
+ source.boxGraph.endTransaction();
329
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
330
+ const regions = deps.filter(box => isInstanceOf(box, ValueRegionBox));
331
+ expect(regions.length).toBe(2);
332
+ expect(regions).toContain(regionA);
333
+ expect(regions).toContain(regionB);
334
+ expect(deps.filter(box => isInstanceOf(box, ValueEventCollectionBox)).length).toBe(1);
335
+ expect(deps.filter(box => isInstanceOf(box, TrackBox)).length).toBe(2);
336
+ });
337
+ it("does not pull in regions from unrelated devices that mirror the same collection", () => {
338
+ const audioUnitA = createAudioUnit(source, 1);
339
+ const effectA = addAudioEffect(source, audioUnitA, "CompA", 0);
340
+ const trackA = addAutomationTrack(source, audioUnitA, effectA.threshold, 0);
341
+ const regionA = addValueRegion(source, trackA, 0, 960);
342
+ const sharedEvents = regionA.events.targetVertex.unwrap().box;
343
+ const audioUnitB = createAudioUnit(source, 2);
344
+ const effectB = addAudioEffect(source, audioUnitB, "CompB", 0);
345
+ const trackB = addAutomationTrack(source, audioUnitB, effectB.threshold, 0);
346
+ source.boxGraph.beginTransaction();
347
+ const unrelatedRegion = ValueRegionBox.create(source.boxGraph, UUID.generate(), box => {
348
+ box.regions.refer(trackB.regions);
349
+ box.events.refer(sharedEvents.owners);
350
+ box.position.setValue(0);
351
+ box.duration.setValue(960);
352
+ });
353
+ source.boxGraph.endTransaction();
354
+ const deps = collectDeviceDependencies(effectA, source.boxGraph);
355
+ const regions = deps.filter(box => isInstanceOf(box, ValueRegionBox));
356
+ expect(regions).toContain(regionA);
357
+ expect(regions).not.toContain(unrelatedRegion);
358
+ const tracks = deps.filter(box => isInstanceOf(box, TrackBox));
359
+ expect(tracks).toContain(trackA);
360
+ expect(tracks).not.toContain(trackB);
361
+ });
362
+ it("round-trip paste of effect with automation events does not throw", () => {
363
+ const sourceAU = createAudioUnit(source);
364
+ const effect = addAudioEffect(source, sourceAU, "Comp", 0);
365
+ const autoTrack = addAutomationTrack(source, sourceAU, effect.threshold, 0);
366
+ addValueRegion(source, autoTrack, 0, 960);
367
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
368
+ const data = ClipboardUtils.serializeBoxes([effect, ...deps]);
369
+ const targetAU = createAudioUnit(target);
370
+ const editing = new BoxEditing(target.boxGraph);
371
+ expect(() => {
372
+ editing.modify(() => {
373
+ ClipboardUtils.deserializeBoxes(data, target.boxGraph, makePasteMapper(targetAU, false, false));
374
+ });
375
+ }).not.toThrow();
376
+ const pastedEffects = targetAU.audioEffects.pointerHub.incoming()
377
+ .filter(pointer => isInstanceOf(pointer.box, CompressorDeviceBox));
378
+ expect(pastedEffects.length).toBe(1);
379
+ const pastedTracks = targetAU.tracks.pointerHub.filter(Pointers.TrackCollection)
380
+ .filter(pointer => isInstanceOf(pointer.box, TrackBox));
381
+ expect(pastedTracks.length).toBe(1);
382
+ });
383
+ it("pasted automation track targets the pasted device's parameter", () => {
384
+ const sourceAU = createAudioUnit(source);
385
+ const effect = addAudioEffect(source, sourceAU, "Comp", 0);
386
+ const autoTrack = addAutomationTrack(source, sourceAU, effect.threshold, 0);
387
+ addValueRegion(source, autoTrack, 0, 960);
388
+ const deps = collectDeviceDependencies(effect, source.boxGraph);
389
+ const data = ClipboardUtils.serializeBoxes([effect, ...deps]);
390
+ const targetAU = createAudioUnit(target);
391
+ const editing = new BoxEditing(target.boxGraph);
392
+ editing.modify(() => {
393
+ ClipboardUtils.deserializeBoxes(data, target.boxGraph, makePasteMapper(targetAU, false, false));
394
+ });
395
+ const pastedEffect = targetAU.audioEffects.pointerHub.incoming()
396
+ .filter(pointer => isInstanceOf(pointer.box, CompressorDeviceBox))
397
+ .map(pointer => pointer.box)[0];
398
+ const pastedTrack = targetAU.tracks.pointerHub.filter(Pointers.TrackCollection)
399
+ .filter(pointer => isInstanceOf(pointer.box, TrackBox))
400
+ .map(pointer => pointer.box)[0];
401
+ expect(pastedTrack).toBeDefined();
402
+ const targetVertex = pastedTrack.target.targetVertex.unwrap();
403
+ expect(targetVertex.box).toBe(pastedEffect);
404
+ });
405
+ });
406
+ // ─────────────────────────────────────────────────────────
287
407
  // Instrument paste
288
408
  // ─────────────────────────────────────────────────────────
289
409
  describe("paste instrument", () => {
@@ -1 +1 @@
1
- {"version":3,"file":"YSync.d.ts","sourceRoot":"","sources":["../../src/ysync/YSync.ts"],"names":[],"mappings":"AAAA,OAAO,EASH,QAAQ,EAER,UAAU,EAIb,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAAa,QAAQ,EAA2D,MAAM,kBAAkB,CAAA;AAE/G,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAIxB,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI;IACvB,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;IACtB,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IACrB,QAAQ,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;CAC/B,CAAA;AAED,qBAAa,KAAK,CAAC,CAAC,CAAE,YAAW,UAAU;;IACvC,MAAM,CAAC,SAAS,EAAE,OAAO,CAAQ;IAEjC,gBAAgB;IAChB,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,GAAG,OAAO;WAItB,YAAY,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;WAcnE,QAAQ,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;gBAqChE,EAAC,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC;IASrD,SAAS,IAAI,IAAI;CA0LpB"}
1
+ {"version":3,"file":"YSync.d.ts","sourceRoot":"","sources":["../../src/ysync/YSync.ts"],"names":[],"mappings":"AAAA,OAAO,EAUH,QAAQ,EAER,UAAU,EAIb,MAAM,kBAAkB,CAAA;AACzB,OAAO,EAEH,QAAQ,EAOX,MAAM,kBAAkB,CAAA;AAEzB,OAAO,KAAK,CAAC,MAAM,KAAK,CAAA;AAIxB,MAAM,MAAM,SAAS,CAAC,CAAC,IAAI;IACvB,QAAQ,EAAE,QAAQ,CAAC,CAAC,CAAC,CAAC;IACtB,KAAK,EAAE,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAA;IACrB,QAAQ,CAAC,EAAE,QAAQ,CAAC,OAAO,CAAC,CAAA;CAC/B,CAAA;AAED,qBAAa,KAAK,CAAC,CAAC,CAAE,YAAW,UAAU;;IACvC,MAAM,CAAC,SAAS,EAAE,OAAO,CAAQ;IAEjC,gBAAgB;IAChB,MAAM,CAAC,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,GAAG,GAAG,OAAO;WAItB,YAAY,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;WAcnE,QAAQ,CAAC,CAAC,EAAE,EAAC,QAAQ,EAAE,KAAK,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC,GAAG,OAAO,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;gBAqChE,EAAC,QAAQ,EAAE,KAAK,EAAE,QAAQ,EAAC,EAAE,SAAS,CAAC,CAAC,CAAC;IASrD,SAAS,IAAI,IAAI;CAiOpB"}
@@ -1,4 +1,5 @@
1
- import { asDefined, asInstanceOf, assert, EmptyExec, isUndefined, Option, panic, Terminable, Terminator, tryCatch, UUID } from "@opendaw/lib-std";
1
+ import { asDefined, asInstanceOf, assert, EmptyExec, isInstanceOf, isUndefined, Option, panic, Terminable, Terminator, tryCatch, UUID } from "@opendaw/lib-std";
2
+ import { optimizeUpdates } from "@opendaw/lib-box";
2
3
  import { YMapper } from "./YMapper";
3
4
  import * as Y from "yjs";
4
5
  export class YSync {
@@ -200,50 +201,95 @@ export class YSync {
200
201
  return Terminable.many(this.#boxGraph.subscribeTransaction({
201
202
  onBeginTransaction: EmptyExec,
202
203
  onEndTransaction: (rolledBack) => {
204
+ const pending = this.#updates.splice(0);
203
205
  if (this.#ignoreUpdates || rolledBack) {
204
- this.#updates.length = 0;
205
206
  return;
206
207
  }
207
- this.#getDoc().transact(() => this.#updates.forEach(update => {
208
- /**
209
- * TRANSFER CHANGES FROM OPENDAW TO YJS
210
- */
211
- if (update.type === "new") {
212
- const uuid = update.uuid;
213
- const key = UUID.toString(uuid);
214
- const box = this.#boxGraph.findBox(uuid).unwrap();
215
- this.#boxes.set(key, YMapper.createBoxMap(box));
216
- }
217
- else if (update.type === "primitive") {
218
- const key = UUID.toString(update.address.uuid);
219
- const boxObject = asDefined(this.#boxes.get(key), "Could not find box");
220
- const { address: { fieldKeys }, newValue } = update;
221
- let field = boxObject.get("fields");
222
- for (let i = 0; i < fieldKeys.length - 1; i++) {
223
- field = asDefined(field.get(String(fieldKeys[i])), `No field at '${fieldKeys[i]}'`);
224
- }
225
- field.set(String(fieldKeys[fieldKeys.length - 1]), newValue);
226
- }
227
- else if (update.type === "pointer") {
228
- const key = UUID.toString(update.address.uuid);
229
- const boxObject = asDefined(this.#boxes.get(key), "Could not find box");
230
- const { address: { fieldKeys }, newAddress } = update;
231
- let field = boxObject.get("fields");
232
- for (let i = 0; i < fieldKeys.length - 1; i++) {
233
- field = asDefined(field.get(String(fieldKeys[i])), `No field at '${fieldKeys[i]}'`);
234
- }
235
- field.set(String(fieldKeys[fieldKeys.length - 1]), newAddress.mapOr(address => address.toString(), null));
236
- }
237
- else if (update.type === "delete") {
238
- this.#boxes.delete(UUID.toString(update.uuid));
239
- }
240
- }), "[openDAW] updates");
241
- this.#updates.length = 0;
208
+ const optimized = optimizeUpdates(pending);
209
+ if (optimized.length === 0) {
210
+ return;
211
+ }
212
+ const result = tryCatch(() => this.#getDoc()
213
+ .transact(() => optimized.forEach(update => this.#applyUpdate(update)), "[openDAW] updates"));
214
+ if (result.status === "failure") {
215
+ console.error("[YSync] flush failed, dropping updates", {
216
+ count: optimized.length,
217
+ error: result.error
218
+ });
219
+ throw result.error;
220
+ }
242
221
  }
243
222
  }), this.#boxGraph.subscribeToAllUpdatesImmediate({
244
223
  onUpdate: (update) => this.#updates.push(update)
245
224
  }));
246
225
  }
226
+ /**
227
+ * TRANSFER ONE CHANGE FROM OPENDAW TO YJS
228
+ */
229
+ #applyUpdate(update) {
230
+ if (update.type === "new") {
231
+ const uuid = update.uuid;
232
+ const key = UUID.toString(uuid);
233
+ const optBox = this.#boxGraph.findBox(uuid);
234
+ if (optBox.isEmpty()) {
235
+ // Phantom box: created and removed in same transaction.
236
+ // optimizeUpdates should have filtered this, but guard in case.
237
+ return;
238
+ }
239
+ this.#boxes.set(key, YMapper.createBoxMap(optBox.unwrap()));
240
+ }
241
+ else if (update.type === "primitive") {
242
+ const key = UUID.toString(update.address.uuid);
243
+ const boxObject = this.#boxes.get(key);
244
+ if (!isInstanceOf(boxObject, Y.Map)) {
245
+ console.warn(`[YSync] primitive update skipped: box '${key}' missing`);
246
+ return;
247
+ }
248
+ const field = this.#resolveFieldMap(boxObject, key, update.address.fieldKeys);
249
+ if (field === undefined) {
250
+ return;
251
+ }
252
+ field.set(String(update.address.fieldKeys[update.address.fieldKeys.length - 1]), update.newValue);
253
+ }
254
+ else if (update.type === "pointer") {
255
+ const key = UUID.toString(update.address.uuid);
256
+ const boxObject = this.#boxes.get(key);
257
+ if (!isInstanceOf(boxObject, Y.Map)) {
258
+ console.warn(`[YSync] pointer update skipped: box '${key}' missing`);
259
+ return;
260
+ }
261
+ const field = this.#resolveFieldMap(boxObject, key, update.address.fieldKeys);
262
+ if (field === undefined) {
263
+ return;
264
+ }
265
+ field.set(String(update.address.fieldKeys[update.address.fieldKeys.length - 1]), update.newAddress.mapOr(address => address.toString(), null));
266
+ }
267
+ else if (update.type === "delete") {
268
+ this.#boxes.delete(UUID.toString(update.uuid));
269
+ }
270
+ }
271
+ /**
272
+ * Walks from the box map down to the Y.Map that owns `fieldKeys[last]`.
273
+ * Returns `undefined` (with a warning) instead of throwing if the path
274
+ * cannot be resolved — protects the yjs transaction from partial writes.
275
+ */
276
+ #resolveFieldMap(boxObject, key, fieldKeys) {
277
+ const fieldsValue = boxObject.get("fields");
278
+ if (!isInstanceOf(fieldsValue, Y.Map)) {
279
+ console.warn(`[YSync] box '${key}' missing 'fields' Y.Map; skipping update`);
280
+ return undefined;
281
+ }
282
+ let field = fieldsValue;
283
+ for (let i = 0; i < fieldKeys.length - 1; i++) {
284
+ const next = field.get(String(fieldKeys[i]));
285
+ if (!isInstanceOf(next, Y.Map)) {
286
+ console.warn(`[YSync] box '${key}' field path broken at '${fieldKeys[i]}'; skipping update`);
287
+ return undefined;
288
+ }
289
+ field = next;
290
+ }
291
+ return field;
292
+ }
247
293
  #getDoc() {
248
294
  return asDefined(this.#boxes.doc, "Y.Map is not connect to Y.Doc");
249
295
  }
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=YSync.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"YSync.test.d.ts","sourceRoot":"","sources":["../../src/ysync/YSync.test.ts"],"names":[],"mappings":""}