1use crate::{
2 audio::io::AudioIO,
3 kind::Kind,
4 message::{Action, ClipMoveFrom, ClipMoveTo},
5 midi::io::MIDIIO,
6 state::State,
7};
8use std::collections::{HashSet, VecDeque};
9use std::sync::Arc;
10
11fn audio_clip_to_data(clip: &crate::audio::clip::AudioClip) -> crate::message::AudioClipData {
12 crate::message::AudioClipData {
13 id: clip.id.clone(),
14 name: clip.name.clone(),
15 start: clip.start,
16 length: clip.end.saturating_sub(clip.start).max(1),
17 offset: clip.offset,
18 input_channel: clip.input_channel,
19 muted: clip.muted,
20 peaks_file: clip.peaks_file.clone(),
21 fade_enabled: clip.fade_enabled,
22 fade_in_samples: clip.fade_in_samples,
23 fade_out_samples: clip.fade_out_samples,
24 preview_name: clip.pitch_correction_preview_name.clone(),
25 source_name: clip.pitch_correction_source_name.clone(),
26 source_offset: clip.pitch_correction_source_offset,
27 source_length: clip.pitch_correction_source_length,
28 pitch_correction_points: clip.pitch_correction_points.clone(),
29 pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
30 pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
31 pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
32 plugin_graph_json: clip.plugin_graph_json.clone(),
33 grouped_clips: clip.grouped_clips.iter().map(audio_clip_to_data).collect(),
34 }
35}
36
37fn midi_clip_to_data(clip: &crate::midi::clip::MIDIClip) -> crate::message::MidiClipData {
38 crate::message::MidiClipData {
39 id: clip.id.clone(),
40 name: clip.name.clone(),
41 start: clip.start,
42 length: clip.end.saturating_sub(clip.start).max(1),
43 offset: clip.offset,
44 input_channel: clip.input_channel,
45 muted: clip.muted,
46 grouped_clips: clip.grouped_clips.iter().map(midi_clip_to_data).collect(),
47 }
48}
49
50#[derive(Clone, Debug)]
51pub struct UndoEntry {
52 pub forward_actions: Vec<Action>,
53 pub inverse_actions: Vec<Action>,
54}
55
56pub struct History {
57 undo_stack: VecDeque<UndoEntry>,
58 redo_stack: VecDeque<UndoEntry>,
59 max_history: usize,
60 save_point: Option<usize>,
61}
62
63impl History {
64 pub fn new(max_history: usize) -> Self {
65 Self {
66 undo_stack: VecDeque::new(),
67 redo_stack: VecDeque::new(),
68 max_history,
69 save_point: None,
70 }
71 }
72
73 pub fn mark_save_point(&mut self) {
74 self.save_point = Some(self.undo_stack.len());
75 }
76
77 pub fn is_dirty(&self) -> bool {
78 match self.save_point {
79 Some(point) => self.undo_stack.len() != point,
80 None => !self.undo_stack.is_empty(),
81 }
82 }
83
84 pub fn record(&mut self, entry: UndoEntry) {
85 self.undo_stack.push_back(entry);
86 self.redo_stack.clear();
87
88 if self.undo_stack.len() > self.max_history {
89 self.undo_stack.pop_front();
90 }
91 }
92
93 pub fn undo(&mut self) -> Option<Vec<Action>> {
94 self.undo_stack.pop_back().map(|entry| {
95 let inverse = entry.inverse_actions.clone();
96 self.redo_stack.push_back(entry);
97 inverse
98 })
99 }
100
101 pub fn redo(&mut self) -> Option<Vec<Action>> {
102 self.redo_stack.pop_back().map(|entry| {
103 let forward = entry.forward_actions.clone();
104 self.undo_stack.push_back(entry);
105 forward
106 })
107 }
108
109 pub fn clear(&mut self) {
110 self.undo_stack.clear();
111 self.redo_stack.clear();
112 }
113}
114
115impl Default for History {
116 fn default() -> Self {
117 Self::new(100)
118 }
119}
120
121pub fn should_record(action: &Action) -> bool {
122 match action {
123 Action::SetTempo(_)
124 | Action::SetLoopEnabled(_)
125 | Action::SetLoopRange(_)
126 | Action::SetPunchEnabled(_)
127 | Action::SetPunchRange(_)
128 | Action::SetMetronomeEnabled(_)
129 | Action::SetTimeSignature { .. }
130 | Action::SetTempoMap { .. }
131 | Action::SetModulators(_)
132 | Action::SetTrackAutomationLanes { .. }
133 | Action::AddTrack { .. }
134 | Action::RemoveTrack(_)
135 | Action::RenameTrack { .. }
136 | Action::TrackLevel(_, _)
137 | Action::TrackBalance(_, _)
138 | Action::TrackToggleArm(_)
139 | Action::TrackToggleMute(_)
140 | Action::TrackTogglePhase(_)
141 | Action::TrackToggleSolo(_)
142 | Action::TrackToggleInputMonitor { .. }
143 | Action::TrackToggleDiskMonitor { .. }
144 | Action::TrackToggleMidiInputMonitor { .. }
145 | Action::TrackToggleMidiDiskMonitor { .. }
146 | Action::TrackSetColor { .. }
147 | Action::TrackSetMidiLearnBinding { .. }
148 | Action::SetGlobalMidiLearnBinding { .. }
149 | Action::SetSessionMidiLearnBinding { .. }
150 | Action::TrackSetFrozen { .. }
151 | Action::TrackSetSessionSlot { .. }
152 | Action::TrackSetSessionSlotPlayEnabled { .. }
153 | Action::TrackSetFolder { .. }
154 | Action::TrackSetParent { .. }
155 | Action::TrackToggleFolder { .. }
156 | Action::TrackToggleMaster(_)
157 | Action::TrackAddAudioInput(_)
158 | Action::TrackAddAudioOutput(_)
159 | Action::TrackRemoveAudioInput(_)
160 | Action::TrackRemoveAudioOutput(_)
161 | Action::AddClip { .. }
162 | Action::AddGroupedClip { .. }
163 | Action::RemoveClip { .. }
164 | Action::RenameClip { .. }
165 | Action::ClipMove { .. }
166 | Action::SetClipFade { .. }
167 | Action::SetClipBounds { .. }
168 | Action::SetClipMuted { .. }
169 | Action::SetClipSourceName { .. }
170 | Action::SetClipPluginGraphJson { .. }
171 | Action::SetClipPitchCorrection { .. }
172 | Action::ClearAllMidiLearnBindings
173 | Action::Connect { .. }
174 | Action::Disconnect { .. }
175 | Action::TrackConnectVst3Audio { .. }
176 | Action::TrackDisconnectVst3Audio { .. }
177 | Action::TrackLoadClapPlugin { .. }
178 | Action::TrackUnloadClapPlugin { .. }
179 | Action::TrackUnloadClapPluginInstance { .. }
180 | Action::TrackLoadVst3Plugin { .. }
181 | Action::TrackUnloadVst3PluginInstance { .. }
182 | Action::TrackSetClapParameter { .. }
183 | Action::ClipSetClapParameter { .. }
184 | Action::TrackSetVst3Parameter { .. }
185 | Action::TrackSetPluginBypassed { .. }
186 | Action::ModifyMidiNotes { .. }
187 | Action::ModifyMidiControllers { .. }
188 | Action::DeleteMidiControllers { .. }
189 | Action::InsertMidiControllers { .. }
190 | Action::DeleteMidiNotes { .. }
191 | Action::InsertMidiNotes { .. }
192 | Action::SetMidiSysExEvents { .. } => true,
193 Action::TrackConnectPluginAudio { .. }
194 | Action::TrackDisconnectPluginAudio { .. }
195 | Action::TrackConnectPluginMidi { .. }
196 | Action::TrackDisconnectPluginMidi { .. }
197 | Action::TrackConnectAudio { .. }
198 | Action::TrackDisconnectAudio { .. }
199 | Action::TrackConnectMidi { .. }
200 | Action::TrackDisconnectMidi { .. } => true,
201 #[cfg(all(unix, not(target_os = "macos")))]
202 Action::TrackLoadLv2Plugin { .. }
203 | Action::TrackUnloadLv2PluginInstance { .. }
204 | Action::TrackSetLv2ControlValue { .. } => true,
205 _ => false,
206 }
207}
208
209pub fn create_inverse_action(action: &Action, state: &State) -> Option<Action> {
210 match action {
211 Action::AddTrack { name, .. } => Some(Action::RemoveTrack(name.clone())),
212
213 Action::RemoveTrack(name) => {
214 let track = state.tracks.get(name)?;
215 let track_lock = track.lock();
216 Some(Action::AddTrack {
217 name: track_lock.name.clone(),
218 audio_ins: track_lock.primary_audio_ins(),
219 midi_ins: track_lock.midi.ins.len(),
220 audio_outs: track_lock.primary_audio_outs(),
221 midi_outs: track_lock.midi.outs.len(),
222 folder: track_lock.is_folder,
223 })
224 }
225
226 Action::RenameTrack { old_name, new_name } => Some(Action::RenameTrack {
227 old_name: new_name.clone(),
228 new_name: old_name.clone(),
229 }),
230
231 Action::TrackLevel(name, _new_level) => {
232 let track = state.tracks.get(name)?;
233 let track_lock = track.lock();
234 Some(Action::TrackLevel(name.clone(), track_lock.level))
235 }
236
237 Action::TrackBalance(name, _new_balance) => {
238 let track = state.tracks.get(name)?;
239 let track_lock = track.lock();
240 Some(Action::TrackBalance(name.clone(), track_lock.balance))
241 }
242
243 Action::TrackToggleArm(name) => Some(Action::TrackToggleArm(name.clone())),
244 Action::TrackToggleMute(name) => Some(Action::TrackToggleMute(name.clone())),
245 Action::TrackTogglePhase(name) => Some(Action::TrackTogglePhase(name.clone())),
246 Action::TrackToggleSolo(name) => Some(Action::TrackToggleSolo(name.clone())),
247 Action::TrackToggleInputMonitor { track_name, lane } => {
248 Some(Action::TrackToggleInputMonitor {
249 track_name: track_name.clone(),
250 lane: *lane,
251 })
252 }
253 Action::TrackToggleDiskMonitor { track_name, lane } => {
254 Some(Action::TrackToggleDiskMonitor {
255 track_name: track_name.clone(),
256 lane: *lane,
257 })
258 }
259 Action::TrackToggleMidiInputMonitor { track_name, lane } => {
260 Some(Action::TrackToggleMidiInputMonitor {
261 track_name: track_name.clone(),
262 lane: *lane,
263 })
264 }
265 Action::TrackToggleMidiDiskMonitor { track_name, lane } => {
266 Some(Action::TrackToggleMidiDiskMonitor {
267 track_name: track_name.clone(),
268 lane: *lane,
269 })
270 }
271 Action::TrackSetColor {
272 track_name,
273 color: _,
274 } => {
275 let track = state.tracks.get(track_name)?;
276 let track_lock = track.lock();
277 Some(Action::TrackSetColor {
278 track_name: track_name.clone(),
279 color: track_lock.color,
280 })
281 }
282 Action::TrackSetMidiLearnBinding {
283 track_name, target, ..
284 } => {
285 let track = state.tracks.get(track_name)?;
286 let track_lock = track.lock();
287 let binding = match target {
288 crate::message::TrackMidiLearnTarget::Volume => {
289 track_lock.midi_learn_volume.clone()
290 }
291 crate::message::TrackMidiLearnTarget::Balance => {
292 track_lock.midi_learn_balance.clone()
293 }
294 crate::message::TrackMidiLearnTarget::Mute => track_lock.midi_learn_mute.clone(),
295 crate::message::TrackMidiLearnTarget::Solo => track_lock.midi_learn_solo.clone(),
296 crate::message::TrackMidiLearnTarget::Arm => track_lock.midi_learn_arm.clone(),
297 crate::message::TrackMidiLearnTarget::InputMonitor => {
298 track_lock.midi_learn_input_monitor.clone()
299 }
300 crate::message::TrackMidiLearnTarget::DiskMonitor => {
301 track_lock.midi_learn_disk_monitor.clone()
302 }
303 };
304 Some(Action::TrackSetMidiLearnBinding {
305 track_name: track_name.clone(),
306 target: *target,
307 binding,
308 })
309 }
310 Action::TrackSetFrozen { track_name, .. } => {
311 let track = state.tracks.get(track_name)?;
312 let track_lock = track.lock();
313 Some(Action::TrackSetFrozen {
314 track_name: track_name.clone(),
315 frozen: track_lock.frozen(),
316 })
317 }
318 Action::TrackSetSessionSlot {
319 track_name,
320 scene_index,
321 clip_id: _,
322 } => {
323 let track = state.tracks.get(track_name)?;
324 let track_lock = track.lock();
325 Some(Action::TrackSetSessionSlot {
326 track_name: track_name.clone(),
327 scene_index: *scene_index,
328 clip_id: track_lock
329 .session_slots
330 .get(scene_index)
331 .map(|slot| slot.clip_id.clone()),
332 })
333 }
334 Action::TrackSetSessionSlotPlayEnabled {
335 track_name,
336 scene_index,
337 enabled: _,
338 } => {
339 let track = state.tracks.get(track_name)?;
340 let track_lock = track.lock();
341 let enabled = track_lock
342 .session_slots
343 .get(scene_index)
344 .map(|slot| slot.play_enabled)
345 .unwrap_or(true);
346 Some(Action::TrackSetSessionSlotPlayEnabled {
347 track_name: track_name.clone(),
348 scene_index: *scene_index,
349 enabled,
350 })
351 }
352 Action::TrackSetFolder {
353 track_name,
354 is_folder: _,
355 } => {
356 let track = state.tracks.get(track_name)?;
357 let track_lock = track.lock();
358 Some(Action::TrackSetFolder {
359 track_name: track_name.clone(),
360 is_folder: track_lock.is_folder,
361 })
362 }
363 Action::TrackSetParent {
364 track_name,
365 parent_name: _,
366 } => {
367 let track = state.tracks.get(track_name)?;
368 let track_lock = track.lock();
369 Some(Action::TrackSetParent {
370 track_name: track_name.clone(),
371 parent_name: track_lock.parent_track.clone(),
372 })
373 }
374 Action::TrackToggleFolder { track_name } => Some(Action::TrackToggleFolder {
375 track_name: track_name.clone(),
376 }),
377 Action::TrackToggleMaster(track_name) => {
378 Some(Action::TrackToggleMaster(track_name.clone()))
379 }
380 Action::TrackAddAudioInput(name) => Some(Action::TrackRemoveAudioInput(name.clone())),
381 Action::TrackAddAudioOutput(name) => Some(Action::TrackRemoveAudioOutput(name.clone())),
382 Action::TrackRemoveAudioInput(name) => Some(Action::TrackAddAudioInput(name.clone())),
383 Action::TrackRemoveAudioOutput(name) => Some(Action::TrackAddAudioOutput(name.clone())),
384
385 Action::AddClip {
386 track_name, kind, ..
387 } => {
388 let track = state.tracks.get(track_name)?;
389 let track_lock = track.lock();
390 let clip_index = match kind {
391 Kind::Audio => track_lock.audio.clips.len(),
392 Kind::MIDI => track_lock.midi.clips.len(),
393 };
394 Some(Action::RemoveClip {
395 track_name: track_name.clone(),
396 kind: *kind,
397 clip_indices: vec![clip_index],
398 })
399 }
400
401 Action::AddGroupedClip {
402 track_name, kind, ..
403 } => {
404 let track = state.tracks.get(track_name)?;
405 let track_lock = track.lock();
406 let clip_index = match kind {
407 Kind::Audio => track_lock.audio.clips.len(),
408 Kind::MIDI => track_lock.midi.clips.len(),
409 };
410 Some(Action::RemoveClip {
411 track_name: track_name.clone(),
412 kind: *kind,
413 clip_indices: vec![clip_index],
414 })
415 }
416
417 Action::RemoveClip {
418 track_name,
419 kind,
420 clip_indices,
421 } => {
422 let track = state.tracks.get(track_name)?;
423 let track_lock = track.lock();
424
425 if clip_indices.len() != 1 {
426 return None;
427 }
428
429 let clip_idx = clip_indices[0];
430 match kind {
431 Kind::Audio => {
432 let clip = track_lock.audio.clips.get(clip_idx)?;
433 if clip.grouped_clips.is_empty() {
434 let length = clip.end.saturating_sub(clip.start);
435 Some(Action::AddClip {
436 clip_id: clip.id.clone(),
437 name: clip.name.clone(),
438 track_name: track_name.clone(),
439 start: clip.start,
440 length,
441 offset: clip.offset,
442 input_channel: clip.input_channel,
443 muted: clip.muted,
444 peaks_file: clip.peaks_file.clone(),
445 kind: Kind::Audio,
446 fade_enabled: clip.fade_enabled,
447 fade_in_samples: clip.fade_in_samples,
448 fade_out_samples: clip.fade_out_samples,
449 source_name: clip.pitch_correction_source_name.clone(),
450 source_offset: clip.pitch_correction_source_offset,
451 source_length: clip.pitch_correction_source_length,
452 preview_name: clip.pitch_correction_preview_name.clone(),
453 pitch_correction_points: clip.pitch_correction_points.clone(),
454 pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
455 pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
456 pitch_correction_formant_compensation: clip
457 .pitch_correction_formant_compensation,
458 plugin_graph_json: clip.plugin_graph_json.clone(),
459 })
460 } else {
461 Some(Action::AddGroupedClip {
462 track_name: track_name.clone(),
463 kind: Kind::Audio,
464 audio_clip: Some(audio_clip_to_data(clip)),
465 midi_clip: None,
466 })
467 }
468 }
469 Kind::MIDI => {
470 let clip = track_lock.midi.clips.get(clip_idx)?;
471 if clip.grouped_clips.is_empty() {
472 let length = clip.end.saturating_sub(clip.start);
473 Some(Action::AddClip {
474 clip_id: clip.id.clone(),
475 name: clip.name.clone(),
476 track_name: track_name.clone(),
477 start: clip.start,
478 length,
479 offset: clip.offset,
480 input_channel: clip.input_channel,
481 muted: clip.muted,
482 peaks_file: None,
483 kind: Kind::MIDI,
484 fade_enabled: true,
485 fade_in_samples: 240,
486 fade_out_samples: 240,
487 source_name: None,
488 source_offset: None,
489 source_length: None,
490 preview_name: None,
491 pitch_correction_points: vec![],
492 pitch_correction_frame_likeness: None,
493 pitch_correction_inertia_ms: None,
494 pitch_correction_formant_compensation: None,
495 plugin_graph_json: None,
496 })
497 } else {
498 Some(Action::AddGroupedClip {
499 track_name: track_name.clone(),
500 kind: Kind::MIDI,
501 audio_clip: None,
502 midi_clip: Some(midi_clip_to_data(clip)),
503 })
504 }
505 }
506 }
507 }
508
509 Action::RenameClip {
510 track_name,
511 kind,
512 clip_index,
513 new_name: _,
514 } => {
515 let track = state.tracks.get(track_name)?;
516 let track_lock = track.lock();
517 let old_name = match kind {
518 Kind::Audio => track_lock.audio.clips.get(*clip_index)?.name.clone(),
519 Kind::MIDI => track_lock.midi.clips.get(*clip_index)?.name.clone(),
520 };
521 Some(Action::RenameClip {
522 track_name: track_name.clone(),
523 kind: *kind,
524 clip_index: *clip_index,
525 new_name: old_name,
526 })
527 }
528
529 Action::ClipMove {
530 kind,
531 from,
532 to,
533 copy,
534 } => {
535 let (original_start, original_input_channel) = {
536 let source_track = state.tracks.get(&from.track_name)?;
537 let source_lock = source_track.lock();
538 match kind {
539 Kind::Audio => {
540 let clip = source_lock.audio.clips.get(from.clip_index)?;
541 (clip.start, clip.input_channel)
542 }
543 Kind::MIDI => {
544 let clip = source_lock.midi.clips.get(from.clip_index)?;
545 (clip.start, clip.input_channel)
546 }
547 }
548 };
549
550 if *copy {
551 let dest_track = state.tracks.get(&to.track_name)?;
552 let dest_lock = dest_track.lock();
553 let clip_idx = match kind {
554 Kind::Audio => dest_lock.audio.clips.len(),
555 Kind::MIDI => dest_lock.midi.clips.len(),
556 };
557 Some(Action::RemoveClip {
558 track_name: to.track_name.clone(),
559 kind: *kind,
560 clip_indices: vec![clip_idx],
561 })
562 } else {
563 let dest_track = state.tracks.get(&to.track_name)?;
564 let dest_lock = dest_track.lock();
565 let dest_len = match kind {
566 Kind::Audio => {
567 if dest_lock.audio.clips.is_empty() {
568 return None;
569 }
570 dest_lock.audio.clips.len()
571 }
572 Kind::MIDI => {
573 if dest_lock.midi.clips.is_empty() {
574 return None;
575 }
576 dest_lock.midi.clips.len()
577 }
578 };
579 let moved_clip_index = if from.track_name == to.track_name {
580 dest_len.saturating_sub(1)
581 } else {
582 dest_len
583 };
584 Some(Action::ClipMove {
585 kind: *kind,
586 from: ClipMoveFrom {
587 track_name: to.track_name.clone(),
588 clip_index: moved_clip_index,
589 },
590 to: ClipMoveTo {
591 track_name: from.track_name.clone(),
592 sample_offset: original_start,
593 input_channel: original_input_channel,
594 },
595 copy: false,
596 })
597 }
598 }
599
600 Action::SetClipFade {
601 track_name,
602 clip_index,
603 kind,
604 ..
605 } => {
606 let track = state.tracks.get(track_name)?;
607 let track_lock = track.lock();
608 match kind {
609 Kind::Audio => {
610 let clip = track_lock.audio.clips.get(*clip_index)?;
611 Some(Action::SetClipFade {
612 track_name: track_name.clone(),
613 clip_index: *clip_index,
614 kind: *kind,
615 fade_enabled: clip.fade_enabled,
616 fade_in_samples: clip.fade_in_samples,
617 fade_out_samples: clip.fade_out_samples,
618 })
619 }
620 Kind::MIDI => Some(Action::SetClipFade {
621 track_name: track_name.clone(),
622 clip_index: *clip_index,
623 kind: *kind,
624 fade_enabled: true,
625 fade_in_samples: 240,
626 fade_out_samples: 240,
627 }),
628 }
629 }
630 Action::SetClipBounds {
631 track_name,
632 clip_index,
633 kind,
634 ..
635 } => {
636 let track = state.tracks.get(track_name)?;
637 let track_lock = track.lock();
638 match kind {
639 Kind::Audio => {
640 let clip = track_lock.audio.clips.get(*clip_index)?;
641 Some(Action::SetClipBounds {
642 track_name: track_name.clone(),
643 clip_index: *clip_index,
644 kind: *kind,
645 start: clip.start,
646 length: clip.end.saturating_sub(clip.start).max(1),
647 offset: clip.offset,
648 })
649 }
650 Kind::MIDI => {
651 let clip = track_lock.midi.clips.get(*clip_index)?;
652 Some(Action::SetClipBounds {
653 track_name: track_name.clone(),
654 clip_index: *clip_index,
655 kind: *kind,
656 start: clip.start,
657 length: clip.end.saturating_sub(clip.start).max(1),
658 offset: clip.offset,
659 })
660 }
661 }
662 }
663 Action::SetClipMuted {
664 track_name,
665 clip_index,
666 kind,
667 ..
668 } => {
669 let track = state.tracks.get(track_name)?;
670 let track_lock = track.lock();
671 let muted = match kind {
672 Kind::Audio => track_lock.audio.clips.get(*clip_index)?.muted,
673 Kind::MIDI => track_lock.midi.clips.get(*clip_index)?.muted,
674 };
675 Some(Action::SetClipMuted {
676 track_name: track_name.clone(),
677 clip_index: *clip_index,
678 kind: *kind,
679 muted,
680 })
681 }
682 Action::SetClipSourceName {
683 track_name,
684 clip_index,
685 kind,
686 ..
687 } => {
688 let track = state.tracks.get(track_name)?;
689 let track_lock = track.lock();
690 let name = match kind {
691 Kind::Audio => track_lock.audio.clips.get(*clip_index)?.name.clone(),
692 Kind::MIDI => track_lock.midi.clips.get(*clip_index)?.name.clone(),
693 };
694 Some(Action::SetClipSourceName {
695 track_name: track_name.clone(),
696 kind: *kind,
697 clip_index: *clip_index,
698 name,
699 })
700 }
701 Action::SetClipPitchCorrection {
702 track_name,
703 clip_index,
704 ..
705 } => {
706 let track = state.tracks.get(track_name)?;
707 let track_lock = track.lock();
708 let clip = track_lock.audio.clips.get(*clip_index)?;
709 Some(Action::SetClipPitchCorrection {
710 track_name: track_name.clone(),
711 clip_index: *clip_index,
712 preview_name: clip.pitch_correction_preview_name.clone(),
713 source_name: clip.pitch_correction_source_name.clone(),
714 source_offset: clip.pitch_correction_source_offset,
715 source_length: clip.pitch_correction_source_length,
716 pitch_correction_points: clip.pitch_correction_points.clone(),
717 pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
718 pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
719 pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
720 })
721 }
722 Action::SetClipPluginGraphJson {
723 track_name,
724 clip_index,
725 plugin_graph_json: _,
726 } => {
727 let track = state.tracks.get(track_name)?;
728 let track_lock = track.lock();
729 let plugin_graph_json = track_lock
730 .audio
731 .clips
732 .get(*clip_index)
733 .map(|clip| clip.plugin_graph_json.clone())
734 .unwrap_or_default();
735 Some(Action::SetClipPluginGraphJson {
736 track_name: track_name.clone(),
737 clip_index: *clip_index,
738 plugin_graph_json,
739 })
740 }
741 Action::SetTrackAutomationLanes {
742 track_name,
743 lanes: _,
744 mode: _,
745 } => {
746 let track = state.tracks.get(track_name)?;
747 let track_lock = track.lock();
748 Some(Action::SetTrackAutomationLanes {
749 track_name: track_name.clone(),
750 lanes: track_lock.automation_lanes.clone(),
751 mode: track_lock.automation_mode,
752 })
753 }
754 Action::Connect {
755 from_track,
756 from_port,
757 to_track,
758 to_port,
759 kind,
760 } => Some(Action::Disconnect {
761 from_track: from_track.clone(),
762 from_port: *from_port,
763 to_track: to_track.clone(),
764 to_port: *to_port,
765 kind: *kind,
766 }),
767
768 Action::Disconnect {
769 from_track,
770 from_port,
771 to_track,
772 to_port,
773 kind,
774 } => Some(Action::Connect {
775 from_track: from_track.clone(),
776 from_port: *from_port,
777 to_track: to_track.clone(),
778 to_port: *to_port,
779 kind: *kind,
780 }),
781 Action::TrackConnectVst3Audio {
782 track_name,
783 from_node,
784 from_port,
785 to_node,
786 to_port,
787 } => Some(Action::TrackDisconnectVst3Audio {
788 track_name: track_name.clone(),
789 from_node: from_node.clone(),
790 from_port: *from_port,
791 to_node: to_node.clone(),
792 to_port: *to_port,
793 }),
794 Action::TrackDisconnectVst3Audio {
795 track_name,
796 from_node,
797 from_port,
798 to_node,
799 to_port,
800 } => Some(Action::TrackConnectVst3Audio {
801 track_name: track_name.clone(),
802 from_node: from_node.clone(),
803 from_port: *from_port,
804 to_node: to_node.clone(),
805 to_port: *to_port,
806 }),
807 Action::TrackConnectPluginAudio {
808 track_name,
809 from_node,
810 from_port,
811 to_node,
812 to_port,
813 } => Some(Action::TrackDisconnectPluginAudio {
814 track_name: track_name.clone(),
815 from_node: from_node.clone(),
816 from_port: *from_port,
817 to_node: to_node.clone(),
818 to_port: *to_port,
819 }),
820 Action::TrackDisconnectPluginAudio {
821 track_name,
822 from_node,
823 from_port,
824 to_node,
825 to_port,
826 } => Some(Action::TrackConnectPluginAudio {
827 track_name: track_name.clone(),
828 from_node: from_node.clone(),
829 from_port: *from_port,
830 to_node: to_node.clone(),
831 to_port: *to_port,
832 }),
833 Action::TrackConnectPluginMidi {
834 track_name,
835 from_node,
836 from_port,
837 to_node,
838 to_port,
839 } => Some(Action::TrackDisconnectPluginMidi {
840 track_name: track_name.clone(),
841 from_node: from_node.clone(),
842 from_port: *from_port,
843 to_node: to_node.clone(),
844 to_port: *to_port,
845 }),
846 Action::TrackDisconnectPluginMidi {
847 track_name,
848 from_node,
849 from_port,
850 to_node,
851 to_port,
852 } => Some(Action::TrackConnectPluginMidi {
853 track_name: track_name.clone(),
854 from_node: from_node.clone(),
855 from_port: *from_port,
856 to_node: to_node.clone(),
857 to_port: *to_port,
858 }),
859 Action::TrackConnectAudio {
860 track_name,
861 from,
862 from_port,
863 to,
864 to_port,
865 } => Some(Action::TrackDisconnectAudio {
866 track_name: track_name.clone(),
867 from: from.clone(),
868 from_port: *from_port,
869 to: to.clone(),
870 to_port: *to_port,
871 }),
872 Action::TrackDisconnectAudio {
873 track_name,
874 from,
875 from_port,
876 to,
877 to_port,
878 } => Some(Action::TrackConnectAudio {
879 track_name: track_name.clone(),
880 from: from.clone(),
881 from_port: *from_port,
882 to: to.clone(),
883 to_port: *to_port,
884 }),
885 Action::TrackConnectMidi {
886 track_name,
887 from,
888 from_port,
889 to,
890 to_port,
891 } => Some(Action::TrackDisconnectMidi {
892 track_name: track_name.clone(),
893 from: from.clone(),
894 from_port: *from_port,
895 to: to.clone(),
896 to_port: *to_port,
897 }),
898 Action::TrackDisconnectMidi {
899 track_name,
900 from,
901 from_port,
902 to,
903 to_port,
904 } => Some(Action::TrackConnectMidi {
905 track_name: track_name.clone(),
906 from: from.clone(),
907 from_port: *from_port,
908 to: to.clone(),
909 to_port: *to_port,
910 }),
911
912 Action::TrackLoadClapPlugin {
913 track_name,
914 plugin_path: _,
915 ..
916 } => {
917 let track = state.tracks.get(track_name)?;
918 let track = track.lock();
919 Some(Action::TrackUnloadClapPluginInstance {
920 track_name: track_name.clone(),
921 instance_id: track.next_clap_instance_id,
922 })
923 }
924 Action::TrackUnloadClapPlugin {
925 track_name,
926 plugin_path,
927 } => Some(Action::TrackLoadClapPlugin {
928 track_name: track_name.clone(),
929 plugin_path: plugin_path.clone(),
930 instance_id: None,
931 }),
932
933 Action::TrackLoadVst3Plugin {
934 track_name,
935 plugin_path: _,
936 ..
937 } => {
938 let track = state.tracks.get(track_name)?;
939 let track = track.lock();
940 Some(Action::TrackUnloadVst3PluginInstance {
941 track_name: track_name.clone(),
942 instance_id: track.next_vst3_instance_id,
943 })
944 }
945 #[cfg(all(unix, not(target_os = "macos")))]
946 Action::TrackLoadLv2Plugin {
947 track_name,
948 plugin_uri: _,
949 ..
950 } => {
951 let track = state.tracks.get(track_name)?;
952 let track = track.lock();
953 Some(Action::TrackUnloadLv2PluginInstance {
954 track_name: track_name.clone(),
955 instance_id: track.next_lv2_instance_id,
956 })
957 }
958 Action::TrackSetClapParameter {
959 track_name,
960 instance_id,
961 ..
962 } => {
963 let track = state.tracks.get(track_name)?;
964 let track = track.lock();
965 let snapshot = track.clap_snapshot_state(*instance_id).ok()?;
966 Some(Action::TrackClapRestoreState {
967 track_name: track_name.clone(),
968 instance_id: *instance_id,
969 state: snapshot,
970 })
971 }
972 Action::ClipSetClapParameter {
973 track_name,
974 clip_idx,
975 instance_id,
976 ..
977 } => {
978 let track = state.tracks.get(track_name)?;
979 let track = track.lock();
980 let (_, snapshot) = track
981 .clip_clap_snapshot_state(*clip_idx, *instance_id)
982 .ok()?;
983 Some(Action::ClipClapRestoreState {
984 track_name: track_name.clone(),
985 clip_idx: *clip_idx,
986 instance_id: *instance_id,
987 state: snapshot,
988 })
989 }
990 Action::TrackSetVst3Parameter {
991 track_name,
992 instance_id,
993 ..
994 } => {
995 let track = state.tracks.get(track_name)?;
996 let track = track.lock();
997 let snapshot = track.vst3_snapshot_state(*instance_id).ok()?;
998 Some(Action::TrackVst3RestoreState {
999 track_name: track_name.clone(),
1000 instance_id: *instance_id,
1001 state: snapshot,
1002 })
1003 }
1004 Action::TrackSetPluginBypassed {
1005 track_name,
1006 instance_id,
1007 format,
1008 bypassed,
1009 } => {
1010 let track = state.tracks.get(track_name)?;
1011 let track = track.lock();
1012 let current_bypassed = match format.as_str() {
1013 "CLAP" => track
1014 .clap_plugins
1015 .iter()
1016 .find(|i| i.id == *instance_id)
1017 .map(|i| i.processor.lock().is_bypassed()),
1018 "VST3" => track
1019 .vst3_plugins
1020 .iter()
1021 .find(|i| i.id == *instance_id)
1022 .map(|i| i.processor.lock().is_bypassed()),
1023 #[cfg(all(unix, not(target_os = "macos")))]
1024 "LV2" => track
1025 .lv2_plugins
1026 .iter()
1027 .find(|i| i.id == *instance_id)
1028 .map(|i| i.processor.lock().is_bypassed()),
1029 _ => None,
1030 };
1031 Some(Action::TrackSetPluginBypassed {
1032 track_name: track_name.clone(),
1033 instance_id: *instance_id,
1034 format: format.clone(),
1035 bypassed: current_bypassed.unwrap_or(!*bypassed),
1036 })
1037 }
1038 #[cfg(all(unix, not(target_os = "macos")))]
1039 Action::TrackSetLv2ControlValue { .. } => None,
1040 Action::ModifyMidiNotes {
1041 track_name,
1042 clip_index,
1043 note_indices,
1044 new_notes,
1045 old_notes,
1046 } => Some(Action::ModifyMidiNotes {
1047 track_name: track_name.clone(),
1048 clip_index: *clip_index,
1049 note_indices: note_indices.clone(),
1050 new_notes: old_notes.clone(),
1051 old_notes: new_notes.clone(),
1052 }),
1053 Action::ModifyMidiControllers {
1054 track_name,
1055 clip_index,
1056 controller_indices,
1057 new_controllers,
1058 old_controllers,
1059 } => Some(Action::ModifyMidiControllers {
1060 track_name: track_name.clone(),
1061 clip_index: *clip_index,
1062 controller_indices: controller_indices.clone(),
1063 new_controllers: old_controllers.clone(),
1064 old_controllers: new_controllers.clone(),
1065 }),
1066 Action::DeleteMidiControllers {
1067 track_name,
1068 clip_index,
1069 deleted_controllers,
1070 ..
1071 } => Some(Action::InsertMidiControllers {
1072 track_name: track_name.clone(),
1073 clip_index: *clip_index,
1074 controllers: deleted_controllers.clone(),
1075 }),
1076 Action::InsertMidiControllers {
1077 track_name,
1078 clip_index,
1079 controllers,
1080 } => {
1081 let mut controller_indices: Vec<usize> =
1082 controllers.iter().map(|(idx, _)| *idx).collect();
1083 controller_indices.sort_unstable_by(|a, b| b.cmp(a));
1084 Some(Action::DeleteMidiControllers {
1085 track_name: track_name.clone(),
1086 clip_index: *clip_index,
1087 controller_indices,
1088 deleted_controllers: controllers.clone(),
1089 })
1090 }
1091
1092 Action::DeleteMidiNotes {
1093 track_name,
1094 clip_index,
1095 deleted_notes,
1096 ..
1097 } => Some(Action::InsertMidiNotes {
1098 track_name: track_name.clone(),
1099 clip_index: *clip_index,
1100 notes: deleted_notes.clone(),
1101 }),
1102
1103 Action::InsertMidiNotes {
1104 track_name,
1105 clip_index,
1106 notes,
1107 } => {
1108 let mut note_indices: Vec<usize> = notes.iter().map(|(idx, _)| *idx).collect();
1109 note_indices.sort_unstable_by(|a, b| b.cmp(a));
1110 Some(Action::DeleteMidiNotes {
1111 track_name: track_name.clone(),
1112 clip_index: *clip_index,
1113 note_indices,
1114 deleted_notes: notes.clone(),
1115 })
1116 }
1117 Action::SetMidiSysExEvents {
1118 track_name,
1119 clip_index,
1120 new_sysex_events,
1121 old_sysex_events,
1122 } => Some(Action::SetMidiSysExEvents {
1123 track_name: track_name.clone(),
1124 clip_index: *clip_index,
1125 new_sysex_events: old_sysex_events.clone(),
1126 old_sysex_events: new_sysex_events.clone(),
1127 }),
1128
1129 _ => None,
1130 }
1131}
1132
1133pub fn create_inverse_actions(action: &Action, state: &State) -> Option<Vec<Action>> {
1134 if let Action::ClearAllMidiLearnBindings = action {
1135 let mut actions = Vec::<Action>::new();
1136 for (track_name, track) in &state.tracks {
1137 let t = track.lock();
1138 let mut push_if_some =
1139 |target: crate::message::TrackMidiLearnTarget,
1140 binding: Option<crate::message::MidiLearnBinding>| {
1141 if binding.is_some() {
1142 actions.push(Action::TrackSetMidiLearnBinding {
1143 track_name: track_name.clone(),
1144 target,
1145 binding,
1146 });
1147 }
1148 };
1149 push_if_some(
1150 crate::message::TrackMidiLearnTarget::Volume,
1151 t.midi_learn_volume.clone(),
1152 );
1153 push_if_some(
1154 crate::message::TrackMidiLearnTarget::Balance,
1155 t.midi_learn_balance.clone(),
1156 );
1157 push_if_some(
1158 crate::message::TrackMidiLearnTarget::Mute,
1159 t.midi_learn_mute.clone(),
1160 );
1161 push_if_some(
1162 crate::message::TrackMidiLearnTarget::Solo,
1163 t.midi_learn_solo.clone(),
1164 );
1165 push_if_some(
1166 crate::message::TrackMidiLearnTarget::Arm,
1167 t.midi_learn_arm.clone(),
1168 );
1169 push_if_some(
1170 crate::message::TrackMidiLearnTarget::InputMonitor,
1171 t.midi_learn_input_monitor.clone(),
1172 );
1173 push_if_some(
1174 crate::message::TrackMidiLearnTarget::DiskMonitor,
1175 t.midi_learn_disk_monitor.clone(),
1176 );
1177 }
1178 return Some(actions);
1179 }
1180
1181 if let Action::TrackUnloadClapPlugin {
1182 track_name,
1183 plugin_path,
1184 } = action
1185 {
1186 let track = state.tracks.get(track_name)?;
1187 let track = track.lock();
1188 let instance = track
1189 .clap_plugins
1190 .iter()
1191 .find(|p| p.processor.lock().path().eq_ignore_ascii_case(plugin_path))?;
1192 let id = instance.id;
1193 let state_snapshot = instance.processor.lock().snapshot_state().ok()?;
1194 return Some(vec![
1195 Action::TrackLoadClapPlugin {
1196 track_name: track_name.clone(),
1197 plugin_path: plugin_path.clone(),
1198 instance_id: Some(id),
1199 },
1200 Action::TrackClapRestoreState {
1201 track_name: track_name.clone(),
1202 instance_id: id,
1203 state: state_snapshot,
1204 },
1205 ]);
1206 }
1207
1208 if let Action::TrackUnloadClapPluginInstance {
1209 track_name,
1210 instance_id,
1211 } = action
1212 {
1213 let track = state.tracks.get(track_name)?;
1214 let track = track.lock();
1215 let instance = track.clap_plugins.iter().find(|p| p.id == *instance_id)?;
1216 let path = instance.processor.lock().path().to_string();
1217 let state_snapshot = instance.processor.lock().snapshot_state().ok()?;
1218 return Some(vec![
1219 Action::TrackLoadClapPlugin {
1220 track_name: track_name.clone(),
1221 plugin_path: path,
1222 instance_id: Some(*instance_id),
1223 },
1224 Action::TrackClapRestoreState {
1225 track_name: track_name.clone(),
1226 instance_id: *instance_id,
1227 state: state_snapshot,
1228 },
1229 ]);
1230 }
1231
1232 if let Action::TrackUnloadVst3PluginInstance {
1233 track_name,
1234 instance_id,
1235 } = action
1236 {
1237 let track = state.tracks.get(track_name)?;
1238 let track = track.lock();
1239 let instance = track.vst3_plugins.iter().find(|p| p.id == *instance_id)?;
1240 let path = instance.processor.lock().path().to_string();
1241 let state_snapshot = instance.processor.lock().snapshot_state().ok()?;
1242 return Some(vec![
1243 Action::TrackLoadVst3Plugin {
1244 track_name: track_name.clone(),
1245 plugin_path: path,
1246 instance_id: Some(*instance_id),
1247 },
1248 Action::TrackVst3RestoreState {
1249 track_name: track_name.clone(),
1250 instance_id: *instance_id,
1251 state: state_snapshot,
1252 },
1253 ]);
1254 }
1255
1256 #[cfg(all(unix, not(target_os = "macos")))]
1257 if let Action::TrackUnloadLv2PluginInstance {
1258 track_name,
1259 instance_id,
1260 } = action
1261 {
1262 let track = state.tracks.get(track_name)?;
1263 let track = track.lock();
1264 let instance = track.lv2_plugins.iter().find(|p| p.id == *instance_id)?;
1265 let uri = instance.processor.lock().uri().to_string();
1266 let state_snapshot = instance.processor.lock().snapshot_state().ok()?;
1267 return Some(vec![
1268 Action::TrackLoadLv2Plugin {
1269 track_name: track_name.clone(),
1270 plugin_uri: uri,
1271 instance_id: Some(*instance_id),
1272 },
1273 Action::TrackSetLv2PluginState {
1274 track_name: track_name.clone(),
1275 instance_id: *instance_id,
1276 state: state_snapshot,
1277 },
1278 ]);
1279 }
1280
1281 if let Action::RemoveTrack(track_name) = action {
1282 let mut actions = Vec::new();
1283 {
1284 let track = state.tracks.get(track_name)?;
1285 let track = track.lock();
1286 actions.push(Action::AddTrack {
1287 name: track.name.clone(),
1288 audio_ins: track.primary_audio_ins(),
1289 midi_ins: track.midi.ins.len(),
1290 audio_outs: track.primary_audio_outs(),
1291 midi_outs: track.midi.outs.len(),
1292 folder: track.is_folder,
1293 });
1294 if let Some(parent_name) = track.parent_track.as_ref() {
1295 actions.push(Action::TrackSetParent {
1296 track_name: track.name.clone(),
1297 parent_name: Some(parent_name.clone()),
1298 });
1299 }
1300 for _ in track.primary_audio_ins()..track.audio.ins.len() {
1301 actions.push(Action::TrackAddAudioInput(track.name.clone()));
1302 }
1303 for _ in track.primary_audio_outs()..track.audio.outs.len() {
1304 actions.push(Action::TrackAddAudioOutput(track.name.clone()));
1305 }
1306
1307 if track.level != 0.0 {
1308 actions.push(Action::TrackLevel(track.name.clone(), track.level));
1309 }
1310 if track.balance != 0.0 {
1311 actions.push(Action::TrackBalance(track.name.clone(), track.balance));
1312 }
1313 if track.armed {
1314 actions.push(Action::TrackToggleArm(track.name.clone()));
1315 }
1316 if track.muted {
1317 actions.push(Action::TrackToggleMute(track.name.clone()));
1318 }
1319 if track.soloed {
1320 actions.push(Action::TrackToggleSolo(track.name.clone()));
1321 }
1322 for (lane, &monitor) in track.input_monitor.iter().enumerate() {
1323 if monitor {
1324 actions.push(Action::TrackToggleInputMonitor {
1325 track_name: track.name.clone(),
1326 lane,
1327 });
1328 }
1329 }
1330 for (lane, &monitor) in track.disk_monitor.iter().enumerate() {
1331 if !monitor {
1332 actions.push(Action::TrackToggleDiskMonitor {
1333 track_name: track.name.clone(),
1334 lane,
1335 });
1336 }
1337 }
1338 for (lane, &monitor) in track.midi_input_monitor.iter().enumerate() {
1339 if monitor {
1340 actions.push(Action::TrackToggleMidiInputMonitor {
1341 track_name: track.name.clone(),
1342 lane,
1343 });
1344 }
1345 }
1346 for (lane, &monitor) in track.midi_disk_monitor.iter().enumerate() {
1347 if !monitor {
1348 actions.push(Action::TrackToggleMidiDiskMonitor {
1349 track_name: track.name.clone(),
1350 lane,
1351 });
1352 }
1353 }
1354 if let Some(color) = track.color {
1355 actions.push(Action::TrackSetColor {
1356 track_name: track.name.clone(),
1357 color: Some(color),
1358 });
1359 }
1360 if track.midi_learn_volume.is_some() {
1361 actions.push(Action::TrackSetMidiLearnBinding {
1362 track_name: track.name.clone(),
1363 target: crate::message::TrackMidiLearnTarget::Volume,
1364 binding: track.midi_learn_volume.clone(),
1365 });
1366 }
1367 if track.midi_learn_balance.is_some() {
1368 actions.push(Action::TrackSetMidiLearnBinding {
1369 track_name: track.name.clone(),
1370 target: crate::message::TrackMidiLearnTarget::Balance,
1371 binding: track.midi_learn_balance.clone(),
1372 });
1373 }
1374 if track.midi_learn_mute.is_some() {
1375 actions.push(Action::TrackSetMidiLearnBinding {
1376 track_name: track.name.clone(),
1377 target: crate::message::TrackMidiLearnTarget::Mute,
1378 binding: track.midi_learn_mute.clone(),
1379 });
1380 }
1381 if track.midi_learn_solo.is_some() {
1382 actions.push(Action::TrackSetMidiLearnBinding {
1383 track_name: track.name.clone(),
1384 target: crate::message::TrackMidiLearnTarget::Solo,
1385 binding: track.midi_learn_solo.clone(),
1386 });
1387 }
1388 if track.midi_learn_arm.is_some() {
1389 actions.push(Action::TrackSetMidiLearnBinding {
1390 track_name: track.name.clone(),
1391 target: crate::message::TrackMidiLearnTarget::Arm,
1392 binding: track.midi_learn_arm.clone(),
1393 });
1394 }
1395 if track.midi_learn_input_monitor.is_some() {
1396 actions.push(Action::TrackSetMidiLearnBinding {
1397 track_name: track.name.clone(),
1398 target: crate::message::TrackMidiLearnTarget::InputMonitor,
1399 binding: track.midi_learn_input_monitor.clone(),
1400 });
1401 }
1402 if track.midi_learn_disk_monitor.is_some() {
1403 actions.push(Action::TrackSetMidiLearnBinding {
1404 track_name: track.name.clone(),
1405 target: crate::message::TrackMidiLearnTarget::DiskMonitor,
1406 binding: track.midi_learn_disk_monitor.clone(),
1407 });
1408 }
1409 for clip in &track.audio.clips {
1410 let length = clip.end.saturating_sub(clip.start).max(1);
1411 actions.push(Action::AddClip {
1412 clip_id: clip.id.clone(),
1413 name: clip.name.clone(),
1414 track_name: track.name.clone(),
1415 start: clip.start,
1416 length,
1417 offset: clip.offset,
1418 input_channel: clip.input_channel,
1419 muted: clip.muted,
1420 peaks_file: clip.peaks_file.clone(),
1421 kind: Kind::Audio,
1422 fade_enabled: clip.fade_enabled,
1423 fade_in_samples: clip.fade_in_samples,
1424 fade_out_samples: clip.fade_out_samples,
1425 source_name: clip.pitch_correction_source_name.clone(),
1426 source_offset: clip.pitch_correction_source_offset,
1427 source_length: clip.pitch_correction_source_length,
1428 preview_name: clip.pitch_correction_preview_name.clone(),
1429 pitch_correction_points: clip.pitch_correction_points.clone(),
1430 pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
1431 pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
1432 pitch_correction_formant_compensation: clip
1433 .pitch_correction_formant_compensation,
1434 plugin_graph_json: clip.plugin_graph_json.clone(),
1435 });
1436 }
1437 for clip in &track.midi.clips {
1438 let length = clip.end.saturating_sub(clip.start).max(1);
1439 actions.push(Action::AddClip {
1440 clip_id: clip.id.clone(),
1441 name: clip.name.clone(),
1442 track_name: track.name.clone(),
1443 start: clip.start,
1444 length,
1445 offset: clip.offset,
1446 input_channel: clip.input_channel,
1447 muted: clip.muted,
1448 peaks_file: None,
1449 kind: Kind::MIDI,
1450 fade_enabled: true,
1451 fade_in_samples: 240,
1452 fade_out_samples: 240,
1453 source_name: None,
1454 source_offset: None,
1455 source_length: None,
1456 preview_name: None,
1457 pitch_correction_points: vec![],
1458 pitch_correction_frame_likeness: None,
1459 pitch_correction_inertia_ms: None,
1460 pitch_correction_formant_compensation: None,
1461 plugin_graph_json: None,
1462 });
1463 }
1464
1465 for instance in &track.vst3_plugins {
1466 let id = instance.id;
1467 let path = instance.processor.lock().path().to_string();
1468 if let Ok(state) = instance.processor.lock().snapshot_state() {
1469 actions.push(Action::TrackLoadVst3Plugin {
1470 track_name: track.name.clone(),
1471 plugin_path: path,
1472 instance_id: Some(id),
1473 });
1474 actions.push(Action::TrackVst3RestoreState {
1475 track_name: track.name.clone(),
1476 instance_id: id,
1477 state,
1478 });
1479 }
1480 }
1481
1482 for (id, path, state) in track.clap_snapshot_all_states() {
1483 actions.push(Action::TrackLoadClapPlugin {
1484 track_name: track.name.clone(),
1485 plugin_path: path,
1486 instance_id: Some(id),
1487 });
1488 actions.push(Action::TrackClapRestoreState {
1489 track_name: track.name.clone(),
1490 instance_id: id,
1491 state,
1492 });
1493 }
1494
1495 #[cfg(all(unix, not(target_os = "macos")))]
1496 for instance in &track.lv2_plugins {
1497 let id = instance.id;
1498 let uri = instance.processor.lock().uri().to_string();
1499 if let Ok(state) = instance.processor.lock().snapshot_state() {
1500 actions.push(Action::TrackLoadLv2Plugin {
1501 track_name: track.name.clone(),
1502 plugin_uri: uri,
1503 instance_id: Some(id),
1504 });
1505 actions.push(Action::TrackSetLv2PluginState {
1506 track_name: track.name.clone(),
1507 instance_id: id,
1508 state,
1509 });
1510 }
1511 }
1512
1513 for conn in &track.plugin_midi_connections {
1514 actions.push(Action::TrackConnectPluginMidi {
1515 track_name: track.name.clone(),
1516 from_node: conn.from_node.clone(),
1517 from_port: conn.from_port,
1518 to_node: conn.to_node.clone(),
1519 to_port: conn.to_port,
1520 });
1521 }
1522 }
1523
1524 let mut seen_audio = std::collections::HashSet::<(String, usize, String, usize)>::new();
1525 let mut seen_midi = std::collections::HashSet::<(String, usize, String, usize)>::new();
1526
1527 let (parent_name, child_names) = {
1530 let track = state.tracks.get(track_name)?;
1531 let track = track.lock();
1532 let parent = track.parent_track.clone();
1533 let children: HashSet<String> = track
1534 .child_tracks
1535 .iter()
1536 .map(|c| c.lock().name.clone())
1537 .collect();
1538 (parent, children)
1539 };
1540 let is_family =
1541 |other: &str| parent_name.as_deref() == Some(other) || child_names.contains(other);
1542
1543 for (from_name, from_track_handle) in &state.tracks {
1544 let from_track = from_track_handle.lock();
1545 for (from_port, out) in from_track.audio.outs.iter().enumerate() {
1546 let conns: Vec<Arc<AudioIO>> = out.connections.lock().to_vec();
1547 for conn in conns {
1548 for (to_name, to_track_handle) in &state.tracks {
1549 let to_track = to_track_handle.lock();
1550 for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
1551 let other_name = if from_name == track_name {
1552 to_name
1553 } else {
1554 from_name
1555 };
1556 if from_name != to_name
1557 && Arc::ptr_eq(&conn, to_in)
1558 && (from_name == track_name || to_name == track_name)
1559 && !is_family(other_name)
1560 && seen_audio.insert((
1561 from_name.clone(),
1562 from_port,
1563 to_name.clone(),
1564 to_port,
1565 ))
1566 {
1567 actions.push(Action::Connect {
1568 from_track: from_name.clone(),
1569 from_port,
1570 to_track: to_name.clone(),
1571 to_port,
1572 kind: Kind::Audio,
1573 });
1574 }
1575 }
1576 }
1577 }
1578 }
1579
1580 for (from_port, out) in from_track.midi.outs.iter().enumerate() {
1581 let conns: Vec<Arc<crate::mutex::UnsafeMutex<Box<MIDIIO>>>> =
1582 out.lock().connections.to_vec();
1583 for conn in conns {
1584 for (to_name, to_track_handle) in &state.tracks {
1585 let to_track = to_track_handle.lock();
1586 for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
1587 let other_name = if from_name == track_name {
1588 to_name
1589 } else {
1590 from_name
1591 };
1592 if from_name != to_name
1593 && Arc::ptr_eq(&conn, to_in)
1594 && (from_name == track_name || to_name == track_name)
1595 && !is_family(other_name)
1596 && seen_midi.insert((
1597 from_name.clone(),
1598 from_port,
1599 to_name.clone(),
1600 to_port,
1601 ))
1602 {
1603 actions.push(Action::Connect {
1604 from_track: from_name.clone(),
1605 from_port,
1606 to_track: to_name.clone(),
1607 to_port,
1608 kind: Kind::MIDI,
1609 });
1610 }
1611 }
1612 }
1613 }
1614 }
1615 }
1616
1617 for (to_name, to_track_handle) in &state.tracks {
1618 if to_name != track_name {
1619 continue;
1620 }
1621 let to_track = to_track_handle.lock();
1622 for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
1623 for (from_name, from_track_handle) in &state.tracks {
1624 let from_track = from_track_handle.lock();
1625 for (from_port, out) in from_track.audio.outs.iter().enumerate() {
1626 let conns: Vec<Arc<AudioIO>> = out.connections.lock().to_vec();
1627 if from_name != to_name
1628 && conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
1629 && !is_family(from_name)
1630 && seen_audio.insert((
1631 from_name.clone(),
1632 from_port,
1633 to_name.clone(),
1634 to_port,
1635 ))
1636 {
1637 actions.push(Action::Connect {
1638 from_track: from_name.clone(),
1639 from_port,
1640 to_track: to_name.clone(),
1641 to_port,
1642 kind: Kind::Audio,
1643 });
1644 }
1645 }
1646 }
1647 }
1648 for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
1649 for (from_name, from_track_handle) in &state.tracks {
1650 let from_track = from_track_handle.lock();
1651 for (from_port, out) in from_track.midi.outs.iter().enumerate() {
1652 let conns: Vec<Arc<crate::mutex::UnsafeMutex<Box<MIDIIO>>>> =
1653 out.lock().connections.to_vec();
1654 if from_name != to_name
1655 && conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
1656 && !is_family(from_name)
1657 && seen_midi.insert((
1658 from_name.clone(),
1659 from_port,
1660 to_name.clone(),
1661 to_port,
1662 ))
1663 {
1664 actions.push(Action::Connect {
1665 from_track: from_name.clone(),
1666 from_port,
1667 to_track: to_name.clone(),
1668 to_port,
1669 kind: Kind::MIDI,
1670 });
1671 }
1672 }
1673 }
1674 }
1675 }
1676
1677 return Some(actions);
1678 }
1679
1680 create_inverse_action(action, state).map(|a| vec![a])
1681}
1682
1683#[cfg(test)]
1684mod tests {
1685 use super::*;
1686 use crate::audio::clip::AudioClip;
1687 use crate::kind::Kind;
1688
1689 use crate::message::{MidiLearnBinding, TrackMidiLearnTarget};
1690 use crate::mutex::UnsafeMutex;
1691 use crate::plugins::types::Vst3PluginState;
1692 use crate::track::Track;
1693 use std::sync::Arc;
1694
1695 fn make_state_with_track(track: Track) -> State {
1696 let mut state = State::default();
1697 state.tracks.insert(
1698 track.name.clone(),
1699 Arc::new(UnsafeMutex::new(Box::new(track))),
1700 );
1701 state
1702 }
1703
1704 fn binding(cc: u8) -> MidiLearnBinding {
1705 MidiLearnBinding {
1706 device: Some("midi".to_string()),
1707 channel: 1,
1708 cc,
1709 }
1710 }
1711
1712 #[test]
1713 fn history_record_limits_size_and_clears_redo_on_new_entry() {
1714 let mut history = History::new(2);
1715 let a = UndoEntry {
1716 forward_actions: vec![Action::SetTempo(120.0)],
1717 inverse_actions: vec![Action::SetTempo(110.0)],
1718 };
1719 let b = UndoEntry {
1720 forward_actions: vec![Action::SetLoopEnabled(true)],
1721 inverse_actions: vec![Action::SetLoopEnabled(false)],
1722 };
1723 let c = UndoEntry {
1724 forward_actions: vec![Action::SetMetronomeEnabled(true)],
1725 inverse_actions: vec![Action::SetMetronomeEnabled(false)],
1726 };
1727
1728 history.record(a);
1729 history.record(b.clone());
1730 history.record(c.clone());
1731
1732 let undo = history.undo().unwrap();
1733 assert!(matches!(
1734 undo.as_slice(),
1735 [Action::SetMetronomeEnabled(false)]
1736 ));
1737
1738 let redo = history.redo().unwrap();
1739 assert!(matches!(
1740 redo.as_slice(),
1741 [Action::SetMetronomeEnabled(true)]
1742 ));
1743
1744 history.undo();
1745 history.record(UndoEntry {
1746 forward_actions: vec![Action::SetClipPlaybackEnabled(true)],
1747 inverse_actions: vec![Action::SetClipPlaybackEnabled(false)],
1748 });
1749
1750 assert!(history.redo().is_none());
1751 let undo = history.undo().unwrap();
1752 assert!(matches!(
1753 undo.as_slice(),
1754 [Action::SetClipPlaybackEnabled(false)]
1755 ));
1756 let undo = history.undo().unwrap();
1757 assert!(matches!(undo.as_slice(), [Action::SetLoopEnabled(false)]));
1758 assert!(history.undo().is_none());
1759 }
1760
1761 #[test]
1762 fn history_clear_removes_pending_undo_and_redo_entries() {
1763 let mut history = History::new(4);
1764 history.record(UndoEntry {
1765 forward_actions: vec![Action::SetTempo(120.0)],
1766 inverse_actions: vec![Action::SetTempo(100.0)],
1767 });
1768 history.record(UndoEntry {
1769 forward_actions: vec![Action::SetLoopEnabled(true)],
1770 inverse_actions: vec![Action::SetLoopEnabled(false)],
1771 });
1772
1773 assert!(history.undo().is_some());
1774 assert!(history.redo().is_some());
1775
1776 history.clear();
1777
1778 assert!(history.undo().is_none());
1779 assert!(history.redo().is_none());
1780 }
1781
1782 #[test]
1783 fn history_with_zero_capacity_discards_recorded_entries() {
1784 let mut history = History::new(0);
1785 history.record(UndoEntry {
1786 forward_actions: vec![Action::SetTempo(120.0)],
1787 inverse_actions: vec![Action::SetTempo(100.0)],
1788 });
1789
1790 assert!(history.undo().is_none());
1791 assert!(history.redo().is_none());
1792 }
1793
1794 #[test]
1795 fn should_record_covers_recent_transport_and_lv2_actions() {
1796 assert!(should_record(&Action::SetLoopEnabled(true)));
1797 assert!(should_record(&Action::SetLoopRange(Some((64, 128)))));
1798 assert!(should_record(&Action::SetPunchEnabled(true)));
1799 assert!(should_record(&Action::SetPunchRange(Some((32, 96)))));
1800 assert!(should_record(&Action::SetMetronomeEnabled(true)));
1801 assert!(!should_record(&Action::SetClipPlaybackEnabled(false)));
1802 assert!(!should_record(&Action::SetRecordEnabled(true)));
1803 assert!(should_record(&Action::SetClipBounds {
1804 track_name: "t".to_string(),
1805 clip_index: 0,
1806 kind: Kind::Audio,
1807 start: 64,
1808 length: 32,
1809 offset: 16,
1810 }));
1811 assert!(should_record(&Action::TrackLoadVst3Plugin {
1812 track_name: "t".to_string(),
1813 plugin_path: "/tmp/test.vst3".to_string(),
1814 instance_id: None,
1815 }));
1816 #[cfg(all(unix, not(target_os = "macos")))]
1817 {
1818 assert!(should_record(&Action::TrackLoadLv2Plugin {
1819 track_name: "t".to_string(),
1820 plugin_uri: "urn:test".to_string(),
1821 instance_id: None,
1822 }));
1823 assert!(should_record(&Action::TrackSetLv2ControlValue {
1824 track_name: "t".to_string(),
1825 instance_id: 0,
1826 index: 1,
1827 value: 0.5,
1828 }));
1829 assert!(!should_record(&Action::TrackSetLv2PluginState {
1830 track_name: "t".to_string(),
1831 instance_id: 0,
1832 state: vec![],
1833 }));
1834 }
1835 assert!(!should_record(&Action::TrackVst3RestoreState {
1836 track_name: "t".to_string(),
1837 instance_id: 0,
1838 state: Vst3PluginState {
1839 plugin_id: "id".to_string(),
1840 component_state: vec![],
1841 controller_state: vec![],
1842 },
1843 }));
1844 }
1845
1846 #[test]
1847 fn create_inverse_action_for_add_clip_targets_next_clip_index() {
1848 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1849 track
1850 .audio
1851 .clips
1852 .push(AudioClip::new("existing".to_string(), 0, 16));
1853 let state = make_state_with_track(track);
1854
1855 let inverse = create_inverse_action(
1856 &Action::AddClip {
1857 clip_id: String::new(),
1858 name: "new".to_string(),
1859 track_name: "t".to_string(),
1860 start: 32,
1861 length: 16,
1862 offset: 0,
1863 input_channel: 0,
1864 muted: false,
1865 peaks_file: None,
1866 kind: Kind::Audio,
1867 fade_enabled: false,
1868 fade_in_samples: 0,
1869 fade_out_samples: 0,
1870 source_name: None,
1871 source_offset: None,
1872 source_length: None,
1873 preview_name: None,
1874 pitch_correction_points: vec![],
1875 pitch_correction_frame_likeness: None,
1876 pitch_correction_inertia_ms: None,
1877 pitch_correction_formant_compensation: None,
1878 plugin_graph_json: None,
1879 },
1880 &state,
1881 )
1882 .unwrap();
1883
1884 match inverse {
1885 Action::RemoveClip {
1886 track_name,
1887 kind,
1888 clip_indices,
1889 } => {
1890 assert_eq!(track_name, "t");
1891 assert_eq!(kind, Kind::Audio);
1892 assert_eq!(clip_indices, vec![1]);
1893 }
1894 other => panic!("unexpected inverse action: {other:?}"),
1895 }
1896 }
1897
1898 #[test]
1899 fn create_inverse_action_for_set_clip_bounds_restores_previous_audio_bounds() {
1900 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
1901 let mut clip = AudioClip::new("clip".to_string(), 10, 30);
1902 clip.offset = 7;
1903 track.audio.clips.push(clip);
1904 let state = make_state_with_track(track);
1905
1906 let inverse = create_inverse_action(
1907 &Action::SetClipBounds {
1908 track_name: "t".to_string(),
1909 clip_index: 0,
1910 kind: Kind::Audio,
1911 start: 14,
1912 length: 22,
1913 offset: 11,
1914 },
1915 &state,
1916 )
1917 .expect("inverse action");
1918
1919 match inverse {
1920 Action::SetClipBounds {
1921 track_name,
1922 clip_index,
1923 kind,
1924 start,
1925 length,
1926 offset,
1927 } => {
1928 assert_eq!(track_name, "t");
1929 assert_eq!(clip_index, 0);
1930 assert_eq!(kind, Kind::Audio);
1931 assert_eq!(start, 10);
1932 assert_eq!(length, 20);
1933 assert_eq!(offset, 7);
1934 }
1935 other => panic!("unexpected inverse action: {other:?}"),
1936 }
1937 }
1938
1939 #[test]
1940 fn create_inverse_action_for_set_clip_bounds_restores_previous_midi_bounds() {
1941 let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
1942 track.midi.clips.push(crate::midi::clip::MIDIClip {
1943 name: "pattern.mid".to_string(),
1944 start: 24,
1945 end: 120,
1946 offset: 9,
1947 ..Default::default()
1948 });
1949 let state = make_state_with_track(track);
1950
1951 let inverse = create_inverse_action(
1952 &Action::SetClipBounds {
1953 track_name: "t".to_string(),
1954 clip_index: 0,
1955 kind: Kind::MIDI,
1956 start: 32,
1957 length: 48,
1958 offset: 4,
1959 },
1960 &state,
1961 )
1962 .expect("inverse action");
1963
1964 match inverse {
1965 Action::SetClipBounds {
1966 track_name,
1967 clip_index,
1968 kind,
1969 start,
1970 length,
1971 offset,
1972 } => {
1973 assert_eq!(track_name, "t");
1974 assert_eq!(clip_index, 0);
1975 assert_eq!(kind, Kind::MIDI);
1976 assert_eq!(start, 24);
1977 assert_eq!(length, 96);
1978 assert_eq!(offset, 9);
1979 }
1980 other => panic!("unexpected inverse action: {other:?}"),
1981 }
1982 }
1983
1984 #[test]
1985 fn create_inverse_action_for_set_clip_muted_restores_audio_and_midi_flags() {
1986 let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
1987 let mut audio_clip = AudioClip::new("audio.wav".to_string(), 0, 16);
1988 audio_clip.muted = true;
1989 track.audio.clips.push(audio_clip);
1990 let midi_clip = crate::midi::clip::MIDIClip {
1991 name: "pattern.mid".to_string(),
1992 muted: false,
1993 ..Default::default()
1994 };
1995 track.midi.clips.push(midi_clip);
1996 let state = make_state_with_track(track);
1997
1998 let audio_inverse = create_inverse_action(
1999 &Action::SetClipMuted {
2000 track_name: "t".to_string(),
2001 clip_index: 0,
2002 kind: Kind::Audio,
2003 muted: false,
2004 },
2005 &state,
2006 )
2007 .expect("audio inverse");
2008 let midi_inverse = create_inverse_action(
2009 &Action::SetClipMuted {
2010 track_name: "t".to_string(),
2011 clip_index: 0,
2012 kind: Kind::MIDI,
2013 muted: true,
2014 },
2015 &state,
2016 )
2017 .expect("midi inverse");
2018
2019 assert!(matches!(
2020 audio_inverse,
2021 Action::SetClipMuted {
2022 muted: true,
2023 kind: Kind::Audio,
2024 ..
2025 }
2026 ));
2027 assert!(matches!(
2028 midi_inverse,
2029 Action::SetClipMuted {
2030 muted: false,
2031 kind: Kind::MIDI,
2032 ..
2033 }
2034 ));
2035 }
2036
2037 #[test]
2038 fn create_inverse_action_for_rename_clip_restores_previous_name() {
2039 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2040 track
2041 .audio
2042 .clips
2043 .push(AudioClip::new("before.wav".to_string(), 0, 16));
2044 let state = make_state_with_track(track);
2045
2046 let inverse = create_inverse_action(
2047 &Action::RenameClip {
2048 track_name: "t".to_string(),
2049 kind: Kind::Audio,
2050 clip_index: 0,
2051 new_name: "after.wav".to_string(),
2052 },
2053 &state,
2054 )
2055 .expect("inverse action");
2056
2057 assert!(matches!(
2058 inverse,
2059 Action::RenameClip { new_name, kind: Kind::Audio, .. } if new_name == "before.wav"
2060 ));
2061 }
2062
2063 #[test]
2064 fn create_inverse_action_for_remove_audio_clip_restores_peaks_file() {
2065 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2066 let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
2067 clip.offset = 12;
2068 clip.input_channel = 0;
2069 clip.muted = true;
2070 clip.peaks_file = Some("peaks/clip.json".to_string());
2071 track.audio.clips.push(clip);
2072 let state = make_state_with_track(track);
2073
2074 let inverse = create_inverse_action(
2075 &Action::RemoveClip {
2076 track_name: "t".to_string(),
2077 kind: Kind::Audio,
2078 clip_indices: vec![0],
2079 },
2080 &state,
2081 )
2082 .expect("inverse action");
2083
2084 match inverse {
2085 Action::AddClip {
2086 name,
2087 track_name,
2088 start,
2089 length,
2090 offset,
2091 input_channel,
2092 muted,
2093 peaks_file,
2094 kind,
2095 ..
2096 } => {
2097 assert_eq!(name, "audio/clip.wav");
2098 assert_eq!(track_name, "t");
2099 assert_eq!(start, 48);
2100 assert_eq!(length, 96);
2101 assert_eq!(offset, 12);
2102 assert_eq!(input_channel, 0);
2103 assert!(muted);
2104 assert_eq!(peaks_file.as_deref(), Some("peaks/clip.json"));
2105 assert_eq!(kind, Kind::Audio);
2106 }
2107 other => panic!("unexpected inverse action: {other:?}"),
2108 }
2109 }
2110
2111 #[test]
2112 fn create_inverse_action_for_remove_grouped_audio_clip_restores_group() {
2113 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2114 let mut group = AudioClip::new("Group".to_string(), 48, 144);
2115 group
2116 .grouped_clips
2117 .push(AudioClip::new("child.wav".to_string(), 0, 32));
2118 track.audio.clips.push(group);
2119 let state = make_state_with_track(track);
2120
2121 let inverse = create_inverse_action(
2122 &Action::RemoveClip {
2123 track_name: "t".to_string(),
2124 kind: Kind::Audio,
2125 clip_indices: vec![0],
2126 },
2127 &state,
2128 )
2129 .expect("inverse action");
2130
2131 match inverse {
2132 Action::AddGroupedClip {
2133 track_name,
2134 kind,
2135 audio_clip,
2136 midi_clip,
2137 } => {
2138 assert_eq!(track_name, "t");
2139 assert_eq!(kind, Kind::Audio);
2140 assert!(midi_clip.is_none());
2141 let audio_clip = audio_clip.expect("audio clip payload");
2142 assert_eq!(audio_clip.name, "Group");
2143 assert_eq!(audio_clip.grouped_clips.len(), 1);
2144 assert_eq!(audio_clip.grouped_clips[0].name, "child.wav");
2145 }
2146 other => panic!("unexpected inverse action: {other:?}"),
2147 }
2148 }
2149
2150 #[test]
2151 fn create_inverse_action_for_remove_midi_clip_restores_clip() {
2152 let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2153 track.midi.clips.push(crate::midi::clip::MIDIClip {
2154 name: "pattern.mid".to_string(),
2155 start: 48,
2156 end: 144,
2157 offset: 12,
2158 input_channel: 3,
2159 muted: true,
2160 ..Default::default()
2161 });
2162 let state = make_state_with_track(track);
2163
2164 let inverse = create_inverse_action(
2165 &Action::RemoveClip {
2166 track_name: "t".to_string(),
2167 kind: Kind::MIDI,
2168 clip_indices: vec![0],
2169 },
2170 &state,
2171 )
2172 .expect("inverse action");
2173
2174 match inverse {
2175 Action::AddClip {
2176 name,
2177 track_name,
2178 start,
2179 length,
2180 offset,
2181 input_channel,
2182 muted,
2183 kind,
2184 ..
2185 } => {
2186 assert_eq!(name, "pattern.mid");
2187 assert_eq!(track_name, "t");
2188 assert_eq!(start, 48);
2189 assert_eq!(length, 96);
2190 assert_eq!(offset, 12);
2191 assert_eq!(input_channel, 3);
2192 assert!(muted);
2193 assert_eq!(kind, Kind::MIDI);
2194 }
2195 other => panic!("unexpected inverse action: {other:?}"),
2196 }
2197 }
2198
2199 #[test]
2200 fn create_inverse_action_for_remove_grouped_midi_clip_restores_group() {
2201 let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2202 let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
2203 group.grouped_clips.push(crate::midi::clip::MIDIClip::new(
2204 "child.mid".to_string(),
2205 0,
2206 48,
2207 ));
2208 track.midi.clips.push(group);
2209 let state = make_state_with_track(track);
2210
2211 let inverse = create_inverse_action(
2212 &Action::RemoveClip {
2213 track_name: "t".to_string(),
2214 kind: Kind::MIDI,
2215 clip_indices: vec![0],
2216 },
2217 &state,
2218 )
2219 .expect("inverse action");
2220
2221 match inverse {
2222 Action::AddGroupedClip {
2223 track_name,
2224 kind,
2225 audio_clip,
2226 midi_clip,
2227 } => {
2228 assert_eq!(track_name, "t");
2229 assert_eq!(kind, Kind::MIDI);
2230 assert!(audio_clip.is_none());
2231 let midi_clip = midi_clip.expect("midi clip payload");
2232 assert_eq!(midi_clip.name, "Group");
2233 assert_eq!(midi_clip.grouped_clips.len(), 1);
2234 assert_eq!(midi_clip.grouped_clips[0].name, "child.mid");
2235 }
2236 other => panic!("unexpected inverse action: {other:?}"),
2237 }
2238 }
2239
2240 #[test]
2241 fn create_inverse_action_for_remove_grouped_audio_clip_preserves_child_metadata() {
2242 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2243 let mut child = AudioClip::new("child.wav".to_string(), 4, 40);
2244 child.peaks_file = Some("peaks/child.json".to_string());
2245 child.pitch_correction_source_name = Some("source.wav".to_string());
2246 child.pitch_correction_source_offset = Some(8);
2247 child.pitch_correction_source_length = Some(24);
2248 child.pitch_correction_preview_name = Some("preview.wav".to_string());
2249 child.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
2250 start_sample: 1,
2251 length_samples: 2,
2252 detected_midi_pitch: 60.0,
2253 target_midi_pitch: 62.0,
2254 clarity: 0.75,
2255 }];
2256 child.pitch_correction_frame_likeness = Some(0.25);
2257 child.pitch_correction_inertia_ms = Some(100);
2258 child.pitch_correction_formant_compensation = Some(true);
2259 child.plugin_graph_json = Some(serde_json::json!({"plugins":[],"connections":[]}));
2260 let mut group = AudioClip::new("Group".to_string(), 48, 144);
2261 group.grouped_clips.push(child);
2262 track.audio.clips.push(group);
2263 let state = make_state_with_track(track);
2264
2265 let inverse = create_inverse_action(
2266 &Action::RemoveClip {
2267 track_name: "t".to_string(),
2268 kind: Kind::Audio,
2269 clip_indices: vec![0],
2270 },
2271 &state,
2272 )
2273 .expect("inverse action");
2274
2275 match inverse {
2276 Action::AddGroupedClip {
2277 audio_clip: Some(audio_clip),
2278 ..
2279 } => {
2280 let child = &audio_clip.grouped_clips[0];
2281 assert_eq!(child.peaks_file.as_deref(), Some("peaks/child.json"));
2282 assert_eq!(child.source_name.as_deref(), Some("source.wav"));
2283 assert_eq!(child.source_offset, Some(8));
2284 assert_eq!(child.source_length, Some(24));
2285 assert_eq!(child.preview_name.as_deref(), Some("preview.wav"));
2286 assert_eq!(child.pitch_correction_points.len(), 1);
2287 assert_eq!(child.pitch_correction_frame_likeness, Some(0.25));
2288 assert_eq!(child.pitch_correction_inertia_ms, Some(100));
2289 assert_eq!(child.pitch_correction_formant_compensation, Some(true));
2290 assert!(child.plugin_graph_json.is_some());
2291 }
2292 other => panic!("unexpected inverse action: {other:?}"),
2293 }
2294 }
2295
2296 #[test]
2297 fn create_inverse_action_for_remove_grouped_midi_clip_preserves_child_structure() {
2298 let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2299 let child = crate::midi::clip::MIDIClip::new("child.mid".to_string(), 0, 48);
2300 let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
2301 group.grouped_clips.push(child);
2302 track.midi.clips.push(group);
2303 let state = make_state_with_track(track);
2304
2305 let inverse = create_inverse_action(
2306 &Action::RemoveClip {
2307 track_name: "t".to_string(),
2308 kind: Kind::MIDI,
2309 clip_indices: vec![0],
2310 },
2311 &state,
2312 )
2313 .expect("inverse action");
2314
2315 match inverse {
2316 Action::AddGroupedClip {
2317 midi_clip: Some(midi_clip),
2318 ..
2319 } => {
2320 let child = &midi_clip.grouped_clips[0];
2321 assert_eq!(child.name, "child.mid");
2322 assert_eq!(child.start, 0);
2323 assert_eq!(child.length, 48);
2324 }
2325 other => panic!("unexpected inverse action: {other:?}"),
2326 }
2327 }
2328
2329 #[test]
2330 fn create_inverse_action_for_set_clip_pitch_correction_restores_previous_values() {
2331 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2332 let mut clip = AudioClip::new("audio.wav".to_string(), 0, 128);
2333 clip.pitch_correction_preview_name = Some("audio_preview.wav".to_string());
2334 clip.pitch_correction_source_name = Some("audio_source.wav".to_string());
2335 clip.pitch_correction_source_offset = Some(12);
2336 clip.pitch_correction_source_length = Some(96);
2337 clip.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
2338 start_sample: 4,
2339 length_samples: 32,
2340 detected_midi_pitch: 60.2,
2341 target_midi_pitch: 61.0,
2342 clarity: 0.8,
2343 }];
2344 clip.pitch_correction_frame_likeness = Some(0.4);
2345 clip.pitch_correction_inertia_ms = Some(250);
2346 clip.pitch_correction_formant_compensation = Some(false);
2347 track.audio.clips.push(clip);
2348 let state = make_state_with_track(track);
2349
2350 let inverse = create_inverse_action(
2351 &Action::SetClipPitchCorrection {
2352 track_name: "t".to_string(),
2353 clip_index: 0,
2354 preview_name: None,
2355 source_name: None,
2356 source_offset: None,
2357 source_length: None,
2358 pitch_correction_points: vec![],
2359 pitch_correction_frame_likeness: None,
2360 pitch_correction_inertia_ms: None,
2361 pitch_correction_formant_compensation: None,
2362 },
2363 &state,
2364 )
2365 .expect("inverse action");
2366
2367 match inverse {
2368 Action::SetClipPitchCorrection {
2369 track_name,
2370 clip_index,
2371 preview_name,
2372 source_name,
2373 source_offset,
2374 source_length,
2375 pitch_correction_points,
2376 pitch_correction_frame_likeness,
2377 pitch_correction_inertia_ms,
2378 pitch_correction_formant_compensation,
2379 } => {
2380 assert_eq!(track_name, "t");
2381 assert_eq!(clip_index, 0);
2382 assert_eq!(preview_name.as_deref(), Some("audio_preview.wav"));
2383 assert_eq!(source_name.as_deref(), Some("audio_source.wav"));
2384 assert_eq!(source_offset, Some(12));
2385 assert_eq!(source_length, Some(96));
2386 assert_eq!(pitch_correction_points.len(), 1);
2387 assert_eq!(pitch_correction_points[0].target_midi_pitch, 61.0);
2388 assert_eq!(pitch_correction_frame_likeness, Some(0.4));
2389 assert_eq!(pitch_correction_inertia_ms, Some(250));
2390 assert_eq!(pitch_correction_formant_compensation, Some(false));
2391 }
2392 other => panic!("unexpected inverse action: {other:?}"),
2393 }
2394 }
2395
2396 #[test]
2397 fn create_inverse_action_for_clip_copy_targets_new_destination_clip() {
2398 let mut source = Track::new("src".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2399 source
2400 .audio
2401 .clips
2402 .push(AudioClip::new("source.wav".to_string(), 12, 48));
2403 let mut dest = Track::new("dst".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2404 dest.audio
2405 .clips
2406 .push(AudioClip::new("existing.wav".to_string(), 0, 24));
2407
2408 let mut state = State::default();
2409 state.tracks.insert(
2410 source.name.clone(),
2411 Arc::new(UnsafeMutex::new(Box::new(source))),
2412 );
2413 state.tracks.insert(
2414 dest.name.clone(),
2415 Arc::new(UnsafeMutex::new(Box::new(dest))),
2416 );
2417
2418 let inverse = create_inverse_action(
2419 &Action::ClipMove {
2420 kind: Kind::Audio,
2421 from: ClipMoveFrom {
2422 track_name: "src".to_string(),
2423 clip_index: 0,
2424 },
2425 to: ClipMoveTo {
2426 track_name: "dst".to_string(),
2427 sample_offset: 96,
2428 input_channel: 0,
2429 },
2430 copy: true,
2431 },
2432 &state,
2433 )
2434 .expect("inverse action");
2435
2436 match inverse {
2437 Action::RemoveClip {
2438 track_name,
2439 kind,
2440 clip_indices,
2441 } => {
2442 assert_eq!(track_name, "dst");
2443 assert_eq!(kind, Kind::Audio);
2444 assert_eq!(clip_indices, vec![1]);
2445 }
2446 other => panic!("unexpected inverse action: {other:?}"),
2447 }
2448 }
2449
2450 #[test]
2451 fn create_inverse_action_for_same_track_clip_move_reverses_last_destination_clip() {
2452 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2453 let mut original = AudioClip::new("clip.wav".to_string(), 20, 40);
2454 original.input_channel = 2;
2455 let moved = AudioClip::new("moved.wav".to_string(), 80, 32);
2456 track.audio.clips.push(original);
2457 track.audio.clips.push(moved);
2458 let state = make_state_with_track(track);
2459
2460 let inverse = create_inverse_action(
2461 &Action::ClipMove {
2462 kind: Kind::Audio,
2463 from: ClipMoveFrom {
2464 track_name: "t".to_string(),
2465 clip_index: 0,
2466 },
2467 to: ClipMoveTo {
2468 track_name: "t".to_string(),
2469 sample_offset: 80,
2470 input_channel: 1,
2471 },
2472 copy: false,
2473 },
2474 &state,
2475 )
2476 .expect("inverse action");
2477
2478 match inverse {
2479 Action::ClipMove {
2480 kind,
2481 from,
2482 to,
2483 copy,
2484 } => {
2485 assert_eq!(kind, Kind::Audio);
2486 assert_eq!(from.track_name, "t");
2487 assert_eq!(from.clip_index, 1);
2488 assert_eq!(to.track_name, "t");
2489 assert_eq!(to.sample_offset, 20);
2490 assert_eq!(to.input_channel, 2);
2491 assert!(!copy);
2492 }
2493 other => panic!("unexpected inverse action: {other:?}"),
2494 }
2495 }
2496
2497 #[test]
2498 fn create_inverse_action_for_track_midi_binding_restores_previous_binding() {
2499 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2500 track.midi_learn_volume = Some(binding(7));
2501 let state = make_state_with_track(track);
2502
2503 let inverse = create_inverse_action(
2504 &Action::TrackSetMidiLearnBinding {
2505 track_name: "t".to_string(),
2506 target: TrackMidiLearnTarget::Volume,
2507 binding: Some(binding(9)),
2508 },
2509 &state,
2510 )
2511 .unwrap();
2512
2513 match inverse {
2514 Action::TrackSetMidiLearnBinding {
2515 track_name,
2516 target,
2517 binding,
2518 } => {
2519 assert_eq!(track_name, "t");
2520 assert_eq!(target, TrackMidiLearnTarget::Volume);
2521 assert_eq!(binding.unwrap().cc, 7);
2522 }
2523 other => panic!("unexpected inverse action: {other:?}"),
2524 }
2525 }
2526
2527 #[test]
2528 fn create_inverse_action_for_vst3_load_uses_next_runtime_instance_id() {
2529 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2530 track.next_vst3_instance_id = 42;
2531 let state = make_state_with_track(track);
2532
2533 let inverse = create_inverse_action(
2534 &Action::TrackLoadVst3Plugin {
2535 track_name: "t".to_string(),
2536 plugin_path: "/tmp/test.vst3".to_string(),
2537 instance_id: None,
2538 },
2539 &state,
2540 )
2541 .unwrap();
2542
2543 match inverse {
2544 Action::TrackUnloadVst3PluginInstance {
2545 track_name,
2546 instance_id,
2547 } => {
2548 assert_eq!(track_name, "t");
2549 assert_eq!(instance_id, 42);
2550 }
2551 other => panic!("unexpected inverse action: {other:?}"),
2552 }
2553 }
2554
2555 #[test]
2556 #[cfg(all(unix, not(target_os = "macos")))]
2557 fn create_inverse_action_for_lv2_load_uses_next_runtime_instance_id() {
2558 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2559 track.next_lv2_instance_id = 5;
2560 let state = make_state_with_track(track);
2561
2562 let inverse = create_inverse_action(
2563 &Action::TrackLoadLv2Plugin {
2564 track_name: "t".to_string(),
2565 plugin_uri: "urn:test".to_string(),
2566 instance_id: None,
2567 },
2568 &state,
2569 )
2570 .unwrap();
2571
2572 match inverse {
2573 Action::TrackUnloadLv2PluginInstance {
2574 track_name,
2575 instance_id,
2576 } => {
2577 assert_eq!(track_name, "t");
2578 assert_eq!(instance_id, 5);
2579 }
2580 other => panic!("unexpected inverse action: {other:?}"),
2581 }
2582 }
2583
2584 #[test]
2585 fn create_inverse_actions_for_clear_all_midi_learn_bindings_restores_only_existing_bindings() {
2586 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2587 track.midi_learn_volume = Some(binding(7));
2588 track.midi_learn_disk_monitor = Some(binding(64));
2589 let state = make_state_with_track(track);
2590
2591 let inverses = create_inverse_actions(&Action::ClearAllMidiLearnBindings, &state).unwrap();
2592
2593 assert_eq!(inverses.len(), 2);
2594 assert!(inverses.iter().any(|action| {
2595 matches!(
2596 action,
2597 Action::TrackSetMidiLearnBinding {
2598 target: TrackMidiLearnTarget::Volume,
2599 binding: Some(MidiLearnBinding { cc: 7, .. }),
2600 ..
2601 }
2602 )
2603 }));
2604 assert!(inverses.iter().any(|action| {
2605 matches!(
2606 action,
2607 Action::TrackSetMidiLearnBinding {
2608 target: TrackMidiLearnTarget::DiskMonitor,
2609 binding: Some(MidiLearnBinding { cc: 64, .. }),
2610 ..
2611 }
2612 )
2613 }));
2614 }
2615
2616 #[test]
2617 fn create_inverse_actions_for_remove_track_restores_io_flags_and_bindings() {
2618 let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
2619 track.level = -3.0;
2620 track.balance = 0.25;
2621 track.armed = true;
2622 track.muted = true;
2623 track.soloed = true;
2624 track.input_monitor = vec![true];
2625 track.disk_monitor = vec![false];
2626 track.midi_learn_volume = Some(binding(10));
2627 track.audio.ins.push(Arc::new(AudioIO::new(64)));
2628 track.audio.outs.push(Arc::new(AudioIO::new(64)));
2629 let state = make_state_with_track(track);
2630
2631 let inverses =
2632 create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
2633
2634 assert!(matches!(
2635 inverses.first(),
2636 Some(Action::AddTrack {
2637 name,
2638 audio_ins: 1,
2639 audio_outs: 1,
2640 midi_ins: 1,
2641 midi_outs: 1,
2642 folder: false,
2643 }) if name == "t"
2644 ));
2645 assert!(
2646 inverses
2647 .iter()
2648 .any(|action| matches!(action, Action::TrackAddAudioInput(name) if name == "t"))
2649 );
2650 assert!(
2651 inverses
2652 .iter()
2653 .any(|action| matches!(action, Action::TrackAddAudioOutput(name) if name == "t"))
2654 );
2655 assert!(
2656 inverses.iter().any(
2657 |action| matches!(action, Action::TrackToggleInputMonitor { track_name, .. } if track_name == "t")
2658 )
2659 );
2660 assert!(
2661 inverses.iter().any(
2662 |action| matches!(action, Action::TrackToggleDiskMonitor { track_name, .. } if track_name == "t")
2663 )
2664 );
2665 assert!(inverses.iter().any(|action| {
2666 matches!(
2667 action,
2668 Action::TrackSetMidiLearnBinding {
2669 target: TrackMidiLearnTarget::Volume,
2670 binding: Some(MidiLearnBinding { cc: 10, .. }),
2671 ..
2672 }
2673 )
2674 }));
2675 }
2676
2677 #[test]
2678 fn create_inverse_actions_for_remove_track_omits_internal_passthrough() {
2679 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2680 track.ensure_default_audio_passthrough();
2681 track.ensure_default_midi_passthrough();
2682 let state = make_state_with_track(track);
2683
2684 let inverses =
2685 create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
2686
2687 assert!(
2688 !inverses.iter().any(|action| matches!(
2689 action,
2690 Action::Connect {
2691 from_track,
2692 to_track,
2693 ..
2694 } if from_track == to_track
2695 )),
2696 "internal passthrough should not be captured as a track-to-track Connect action"
2697 );
2698 }
2699
2700 #[test]
2701 fn create_inverse_actions_for_remove_folder_track_restores_parent_and_omits_child_wiring() {
2702 let mut state = State::default();
2703 let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2704 let child = Track::new("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2705 state.tracks.insert(
2706 "folder".to_string(),
2707 Arc::new(UnsafeMutex::new(Box::new(folder))),
2708 );
2709 state.tracks.insert(
2710 "child".to_string(),
2711 Arc::new(UnsafeMutex::new(Box::new(child))),
2712 );
2713 {
2714 let folder_arc = state.tracks.get("folder").unwrap().clone();
2715 let child_arc = state.tracks.get("child").unwrap().clone();
2716 child_arc.lock().parent_track = Some("folder".to_string());
2717 folder_arc.lock().child_tracks.push(child_arc.clone());
2718
2719 let (folder_in, folder_out) = {
2721 let folder = folder_arc.lock();
2722 (folder.audio.ins[0].clone(), folder.audio.outs[0].clone())
2723 };
2724 let (child_in, child_out) = {
2725 let child = child_arc.lock();
2726 (child.audio.ins[0].clone(), child.audio.outs[0].clone())
2727 };
2728 AudioIO::connect(&folder_in, &child_in);
2729 AudioIO::connect(&child_out, &folder_out);
2730 }
2731
2732 let mut inverses =
2733 create_inverse_actions(&Action::RemoveTrack("folder".to_string()), &state).unwrap();
2734 inverses.extend(
2735 create_inverse_actions(&Action::RemoveTrack("child".to_string()), &state).unwrap(),
2736 );
2737
2738 assert!(
2739 inverses.iter().any(|action| matches!(
2740 action,
2741 Action::TrackSetParent {
2742 track_name,
2743 parent_name: Some(parent_name),
2744 } if track_name == "child" && parent_name == "folder"
2745 )),
2746 "inverse should restore the child-to-folder parent relationship"
2747 );
2748 assert!(
2749 !inverses.iter().any(|action| matches!(
2750 action,
2751 Action::Connect { from_track, to_track, .. }
2752 if (from_track == "folder" && to_track == "child")
2753 || (from_track == "child" && to_track == "folder")
2754 )),
2755 "implicit folder-to-child wiring should not be captured as a generic Connect action"
2756 );
2757 }
2758
2759 #[test]
2760 fn create_inverse_action_for_track_set_session_slot_restores_previous_clip() {
2761 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2762 track
2763 .session_slots
2764 .insert(0, crate::track::SessionSlot::new("old-clip".to_string()));
2765 let state = make_state_with_track(track);
2766
2767 let inverse = create_inverse_action(
2768 &Action::TrackSetSessionSlot {
2769 track_name: "t".to_string(),
2770 scene_index: 0,
2771 clip_id: Some("new-clip".to_string()),
2772 },
2773 &state,
2774 )
2775 .unwrap();
2776
2777 match inverse {
2778 Action::TrackSetSessionSlot {
2779 track_name,
2780 scene_index,
2781 clip_id,
2782 } => {
2783 assert_eq!(track_name, "t");
2784 assert_eq!(scene_index, 0);
2785 assert_eq!(clip_id, Some("old-clip".to_string()));
2786 }
2787 other => panic!("unexpected inverse action: {other:?}"),
2788 }
2789 }
2790
2791 #[test]
2792 fn create_inverse_action_for_track_set_session_slot_clear_restores_none() {
2793 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2794 track
2795 .session_slots
2796 .insert(0, crate::track::SessionSlot::new("old-clip".to_string()));
2797 let state = make_state_with_track(track);
2798
2799 let inverse = create_inverse_action(
2800 &Action::TrackSetSessionSlot {
2801 track_name: "t".to_string(),
2802 scene_index: 0,
2803 clip_id: None,
2804 },
2805 &state,
2806 )
2807 .unwrap();
2808
2809 match inverse {
2810 Action::TrackSetSessionSlot {
2811 track_name,
2812 scene_index,
2813 clip_id,
2814 } => {
2815 assert_eq!(track_name, "t");
2816 assert_eq!(scene_index, 0);
2817 assert_eq!(clip_id, Some("old-clip".to_string()));
2818 }
2819 other => panic!("unexpected inverse action: {other:?}"),
2820 }
2821 }
2822
2823 #[test]
2824 fn create_inverse_action_for_track_set_session_slot_play_enabled_restores_previous_flag() {
2825 let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2826 track
2827 .session_slots
2828 .insert(0, crate::track::SessionSlot::new("clip".to_string()));
2829 track.session_slots.get_mut(&0).unwrap().play_enabled = false;
2830 let state = make_state_with_track(track);
2831
2832 let inverse = create_inverse_action(
2833 &Action::TrackSetSessionSlotPlayEnabled {
2834 track_name: "t".to_string(),
2835 scene_index: 0,
2836 enabled: true,
2837 },
2838 &state,
2839 )
2840 .unwrap();
2841
2842 match inverse {
2843 Action::TrackSetSessionSlotPlayEnabled {
2844 track_name,
2845 scene_index,
2846 enabled,
2847 } => {
2848 assert_eq!(track_name, "t");
2849 assert_eq!(scene_index, 0);
2850 assert!(!enabled);
2851 }
2852 other => panic!("unexpected inverse action: {other:?}"),
2853 }
2854 }
2855}