Skip to main content

maolan_engine/
history.rs

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