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    if let Action::ClipMove {
1827        kind: Kind::Audio,
1828        from,
1829        to,
1830        copy,
1831    } = action
1832    {
1833        let mut actions = vec![create_inverse_action(action, state)?];
1834        if *copy {
1835            let dest_track = state.tracks.get(&to.track_name)?;
1836            let dest_track = dest_track.lock();
1837            append_audio_clip_fade_restores(
1838                &mut actions,
1839                &to.track_name,
1840                &dest_track.audio.clips(),
1841            );
1842        } else if from.track_name == to.track_name {
1843            let track = state.tracks.get(&from.track_name)?;
1844            let track = track.lock();
1845            let mut clips = track.audio.clips().iter().cloned().collect::<Vec<_>>();
1846            if from.clip_index < clips.len() {
1847                let moved = clips.remove(from.clip_index);
1848                clips.push(moved);
1849            }
1850            append_audio_clip_fade_restores(&mut actions, &from.track_name, &clips);
1851        } else {
1852            let source_track = state.tracks.get(&from.track_name)?;
1853            let source_track = source_track.lock();
1854            let mut source_clips = source_track
1855                .audio
1856                .clips()
1857                .iter()
1858                .cloned()
1859                .collect::<Vec<_>>();
1860            if from.clip_index < source_clips.len() {
1861                let moved = source_clips.remove(from.clip_index);
1862                source_clips.push(moved);
1863            }
1864            append_audio_clip_fade_restores(&mut actions, &from.track_name, &source_clips);
1865
1866            let dest_track = state.tracks.get(&to.track_name)?;
1867            let dest_track = dest_track.lock();
1868            append_audio_clip_fade_restores(
1869                &mut actions,
1870                &to.track_name,
1871                &dest_track.audio.clips(),
1872            );
1873        }
1874        return Some(actions);
1875    }
1876
1877    create_inverse_action(action, state).map(|a| vec![a])
1878}
1879
1880fn append_audio_clip_fade_restores(
1881    actions: &mut Vec<Action>,
1882    track_name: &str,
1883    clips: &[Arc<crate::audio::clip::AudioClip>],
1884) {
1885    actions.extend(
1886        clips
1887            .iter()
1888            .enumerate()
1889            .map(|(clip_index, clip)| Action::SetClipFade {
1890                track_name: track_name.to_string(),
1891                clip_index,
1892                kind: Kind::Audio,
1893                fade_enabled: clip.fade_enabled,
1894                fade_in_samples: clip.fade_in_samples,
1895                fade_out_samples: clip.fade_out_samples,
1896            }),
1897    );
1898}
1899
1900#[cfg(test)]
1901mod tests {
1902    use super::*;
1903    use crate::audio::clip::AudioClip;
1904    use crate::kind::Kind;
1905
1906    use crate::message::{MidiLearnBinding, TrackMidiLearnTarget};
1907    use crate::plugins::types::Vst3PluginState;
1908    use crate::track::Track;
1909    use std::sync::Arc;
1910
1911    fn make_state_with_track(track: Track) -> State {
1912        let mut state = State::default();
1913        state.tracks.insert(track.name.clone(), Arc::new(track));
1914        state
1915    }
1916
1917    fn binding(cc: u8) -> MidiLearnBinding {
1918        MidiLearnBinding {
1919            device: Some("midi".to_string()),
1920            channel: 1,
1921            cc,
1922        }
1923    }
1924
1925    #[test]
1926    fn history_record_limits_size_and_clears_redo_on_new_entry() {
1927        let mut history = History::new(2);
1928        let a = UndoEntry {
1929            forward_actions: vec![Action::SetTempo(120.0)],
1930            inverse_actions: vec![Action::SetTempo(110.0)],
1931        };
1932        let b = UndoEntry {
1933            forward_actions: vec![Action::SetLoopEnabled(true)],
1934            inverse_actions: vec![Action::SetLoopEnabled(false)],
1935        };
1936        let c = UndoEntry {
1937            forward_actions: vec![Action::SetMetronomeEnabled(true)],
1938            inverse_actions: vec![Action::SetMetronomeEnabled(false)],
1939        };
1940
1941        history.record(a);
1942        history.record(b.clone());
1943        history.record(c.clone());
1944
1945        let undo = history.undo().unwrap();
1946        assert!(matches!(
1947            undo.as_slice(),
1948            [Action::SetMetronomeEnabled(false)]
1949        ));
1950
1951        let redo = history.redo().unwrap();
1952        assert!(matches!(
1953            redo.as_slice(),
1954            [Action::SetMetronomeEnabled(true)]
1955        ));
1956
1957        history.undo();
1958        history.record(UndoEntry {
1959            forward_actions: vec![Action::SetClipPlaybackEnabled(true)],
1960            inverse_actions: vec![Action::SetClipPlaybackEnabled(false)],
1961        });
1962
1963        assert!(history.redo().is_none());
1964        let undo = history.undo().unwrap();
1965        assert!(matches!(
1966            undo.as_slice(),
1967            [Action::SetClipPlaybackEnabled(false)]
1968        ));
1969        let undo = history.undo().unwrap();
1970        assert!(matches!(undo.as_slice(), [Action::SetLoopEnabled(false)]));
1971        assert!(history.undo().is_none());
1972    }
1973
1974    #[test]
1975    fn inverse_audio_clip_move_restores_cross_section_fades() {
1976        let track = Track::new("Synth".to_string(), 1, 1, 0, 0, 128, 48_000.0);
1977        let mut first = AudioClip::new("first.wav".to_string(), 0, 100);
1978        first.fade_in_samples = 11;
1979        first.fade_out_samples = 12;
1980        let mut second = AudioClip::new("second.wav".to_string(), 150, 250);
1981        second.fade_in_samples = 21;
1982        second.fade_out_samples = 22;
1983        track.audio.push_clip(first);
1984        track.audio.push_clip(second);
1985        let state = make_state_with_track(track);
1986
1987        let actions = create_inverse_actions(
1988            &Action::ClipMove {
1989                kind: Kind::Audio,
1990                from: ClipMoveFrom {
1991                    track_name: "Synth".to_string(),
1992                    clip_index: 0,
1993                },
1994                to: ClipMoveTo {
1995                    track_name: "Synth".to_string(),
1996                    sample_offset: 80,
1997                    input_channel: 0,
1998                },
1999                copy: false,
2000            },
2001            &state,
2002        )
2003        .unwrap();
2004
2005        assert!(matches!(actions[0], Action::ClipMove { .. }));
2006        assert!(matches!(
2007            actions[1],
2008            Action::SetClipFade {
2009                clip_index: 0,
2010                fade_in_samples: 21,
2011                fade_out_samples: 22,
2012                ..
2013            }
2014        ));
2015        assert!(matches!(
2016            actions[2],
2017            Action::SetClipFade {
2018                clip_index: 1,
2019                fade_in_samples: 11,
2020                fade_out_samples: 12,
2021                ..
2022            }
2023        ));
2024    }
2025
2026    #[test]
2027    fn history_clear_removes_pending_undo_and_redo_entries() {
2028        let mut history = History::new(4);
2029        history.record(UndoEntry {
2030            forward_actions: vec![Action::SetTempo(120.0)],
2031            inverse_actions: vec![Action::SetTempo(100.0)],
2032        });
2033        history.record(UndoEntry {
2034            forward_actions: vec![Action::SetLoopEnabled(true)],
2035            inverse_actions: vec![Action::SetLoopEnabled(false)],
2036        });
2037
2038        assert!(history.undo().is_some());
2039        assert!(history.redo().is_some());
2040
2041        history.clear();
2042
2043        assert!(history.undo().is_none());
2044        assert!(history.redo().is_none());
2045    }
2046
2047    #[test]
2048    fn history_with_zero_capacity_discards_recorded_entries() {
2049        let mut history = History::new(0);
2050        history.record(UndoEntry {
2051            forward_actions: vec![Action::SetTempo(120.0)],
2052            inverse_actions: vec![Action::SetTempo(100.0)],
2053        });
2054
2055        assert!(history.undo().is_none());
2056        assert!(history.redo().is_none());
2057    }
2058
2059    #[test]
2060    fn should_record_covers_recent_transport_and_lv2_actions() {
2061        assert!(should_record(&Action::SetLoopEnabled(true)));
2062        assert!(should_record(&Action::SetLoopRange(Some((64, 128)))));
2063        assert!(should_record(&Action::SetPunchEnabled(true)));
2064        assert!(should_record(&Action::SetPunchRange(Some((32, 96)))));
2065        assert!(should_record(&Action::SetMetronomeEnabled(true)));
2066        assert!(!should_record(&Action::SetClipPlaybackEnabled(false)));
2067        assert!(!should_record(&Action::SetRecordEnabled(true)));
2068        assert!(should_record(&Action::SetClipBounds {
2069            track_name: "t".to_string(),
2070            clip_index: 0,
2071            kind: Kind::Audio,
2072            start: 64,
2073            length: 32,
2074            offset: 16,
2075        }));
2076        assert!(should_record(&Action::TrackLoadVst3Plugin {
2077            track_name: "t".to_string(),
2078            plugin_id: "/tmp/test.vst3".to_string(),
2079            instance_id: None,
2080        }));
2081        #[cfg(all(unix, not(target_os = "macos")))]
2082        {
2083            assert!(should_record(&Action::TrackLoadLv2Plugin {
2084                track_name: "t".to_string(),
2085                plugin_uri: "urn:test".to_string(),
2086                instance_id: None,
2087            }));
2088            assert!(should_record(&Action::TrackSetLv2ControlValue {
2089                track_name: "t".to_string(),
2090                instance_id: 0,
2091                index: 1,
2092                value: 0.5,
2093            }));
2094            assert!(!should_record(&Action::TrackSetLv2PluginState {
2095                track_name: "t".to_string(),
2096                instance_id: 0,
2097                state: vec![],
2098            }));
2099        }
2100        assert!(!should_record(&Action::TrackVst3RestoreState {
2101            track_name: "t".to_string(),
2102            instance_id: 0,
2103            state: Vst3PluginState {
2104                plugin_id: "id".to_string(),
2105                component_state: vec![],
2106                controller_state: vec![],
2107            },
2108        }));
2109    }
2110
2111    #[test]
2112    fn create_inverse_action_for_add_clip_targets_next_clip_index() {
2113        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2114        track
2115            .audio
2116            .push_clip(AudioClip::new("existing".to_string(), 0, 16));
2117        let state = make_state_with_track(track);
2118
2119        let inverse = create_inverse_action(
2120            &Action::AddClip {
2121                clip_id: String::new(),
2122                name: "new".to_string(),
2123                track_name: "t".to_string(),
2124                start: 32,
2125                length: 16,
2126                offset: 0,
2127                input_channel: 0,
2128                muted: false,
2129                peaks_file: None,
2130                kind: Kind::Audio,
2131                fade_enabled: false,
2132                fade_in_samples: 0,
2133                fade_out_samples: 0,
2134                source_name: None,
2135                source_offset: None,
2136                source_length: None,
2137                preview_name: None,
2138                pitch_correction_points: vec![],
2139                pitch_correction_frame_likeness: None,
2140                pitch_correction_inertia_ms: None,
2141                pitch_correction_formant_compensation: None,
2142                plugin_graph_json: None,
2143            },
2144            &state,
2145        )
2146        .unwrap();
2147
2148        match inverse {
2149            Action::RemoveClip {
2150                track_name,
2151                kind,
2152                clip_indices,
2153            } => {
2154                assert_eq!(track_name, "t");
2155                assert_eq!(kind, Kind::Audio);
2156                assert_eq!(clip_indices, vec![1]);
2157            }
2158            other => panic!("unexpected inverse action: {other:?}"),
2159        }
2160    }
2161
2162    #[test]
2163    fn create_inverse_action_for_set_clip_bounds_restores_previous_audio_bounds() {
2164        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2165        let mut clip = AudioClip::new("clip".to_string(), 10, 30);
2166        clip.offset = 7;
2167        track.audio.push_clip(clip);
2168        let state = make_state_with_track(track);
2169
2170        let inverse = create_inverse_action(
2171            &Action::SetClipBounds {
2172                track_name: "t".to_string(),
2173                clip_index: 0,
2174                kind: Kind::Audio,
2175                start: 14,
2176                length: 22,
2177                offset: 11,
2178            },
2179            &state,
2180        )
2181        .expect("inverse action");
2182
2183        match inverse {
2184            Action::SetClipBounds {
2185                track_name,
2186                clip_index,
2187                kind,
2188                start,
2189                length,
2190                offset,
2191            } => {
2192                assert_eq!(track_name, "t");
2193                assert_eq!(clip_index, 0);
2194                assert_eq!(kind, Kind::Audio);
2195                assert_eq!(start, 10);
2196                assert_eq!(length, 20);
2197                assert_eq!(offset, 7);
2198            }
2199            other => panic!("unexpected inverse action: {other:?}"),
2200        }
2201    }
2202
2203    #[test]
2204    fn create_inverse_action_for_set_clip_bounds_restores_previous_midi_bounds() {
2205        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2206        track.midi.push_clip(crate::midi::clip::MIDIClip {
2207            name: "pattern.mid".to_string(),
2208            start: 24,
2209            end: 120,
2210            offset: 9,
2211            ..Default::default()
2212        });
2213        let state = make_state_with_track(track);
2214
2215        let inverse = create_inverse_action(
2216            &Action::SetClipBounds {
2217                track_name: "t".to_string(),
2218                clip_index: 0,
2219                kind: Kind::MIDI,
2220                start: 32,
2221                length: 48,
2222                offset: 4,
2223            },
2224            &state,
2225        )
2226        .expect("inverse action");
2227
2228        match inverse {
2229            Action::SetClipBounds {
2230                track_name,
2231                clip_index,
2232                kind,
2233                start,
2234                length,
2235                offset,
2236            } => {
2237                assert_eq!(track_name, "t");
2238                assert_eq!(clip_index, 0);
2239                assert_eq!(kind, Kind::MIDI);
2240                assert_eq!(start, 24);
2241                assert_eq!(length, 96);
2242                assert_eq!(offset, 9);
2243            }
2244            other => panic!("unexpected inverse action: {other:?}"),
2245        }
2246    }
2247
2248    #[test]
2249    fn create_inverse_action_for_set_clip_muted_restores_audio_and_midi_flags() {
2250        let track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
2251        let mut audio_clip = AudioClip::new("audio.wav".to_string(), 0, 16);
2252        audio_clip.muted = true;
2253        track.audio.push_clip(audio_clip);
2254        let midi_clip = crate::midi::clip::MIDIClip {
2255            name: "pattern.mid".to_string(),
2256            muted: false,
2257            ..Default::default()
2258        };
2259        track.midi.push_clip(midi_clip);
2260        let state = make_state_with_track(track);
2261
2262        let audio_inverse = create_inverse_action(
2263            &Action::SetClipMuted {
2264                track_name: "t".to_string(),
2265                clip_index: 0,
2266                kind: Kind::Audio,
2267                muted: false,
2268            },
2269            &state,
2270        )
2271        .expect("audio inverse");
2272        let midi_inverse = create_inverse_action(
2273            &Action::SetClipMuted {
2274                track_name: "t".to_string(),
2275                clip_index: 0,
2276                kind: Kind::MIDI,
2277                muted: true,
2278            },
2279            &state,
2280        )
2281        .expect("midi inverse");
2282
2283        assert!(matches!(
2284            audio_inverse,
2285            Action::SetClipMuted {
2286                muted: true,
2287                kind: Kind::Audio,
2288                ..
2289            }
2290        ));
2291        assert!(matches!(
2292            midi_inverse,
2293            Action::SetClipMuted {
2294                muted: false,
2295                kind: Kind::MIDI,
2296                ..
2297            }
2298        ));
2299    }
2300
2301    #[test]
2302    fn create_inverse_action_for_rename_clip_restores_previous_name() {
2303        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2304        track
2305            .audio
2306            .push_clip(AudioClip::new("before.wav".to_string(), 0, 16));
2307        let state = make_state_with_track(track);
2308
2309        let inverse = create_inverse_action(
2310            &Action::RenameClip {
2311                track_name: "t".to_string(),
2312                kind: Kind::Audio,
2313                clip_index: 0,
2314                new_name: "after.wav".to_string(),
2315            },
2316            &state,
2317        )
2318        .expect("inverse action");
2319
2320        assert!(matches!(
2321            inverse,
2322            Action::RenameClip { new_name, kind: Kind::Audio, .. } if new_name == "before.wav"
2323        ));
2324    }
2325
2326    #[test]
2327    fn create_inverse_action_for_remove_audio_clip_restores_peaks_file() {
2328        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2329        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
2330        clip.offset = 12;
2331        clip.input_channel = 0;
2332        clip.muted = true;
2333        clip.peaks_file = Some("peaks/clip.json".to_string());
2334        track.audio.push_clip(clip);
2335        let state = make_state_with_track(track);
2336
2337        let inverse = create_inverse_action(
2338            &Action::RemoveClip {
2339                track_name: "t".to_string(),
2340                kind: Kind::Audio,
2341                clip_indices: vec![0],
2342            },
2343            &state,
2344        )
2345        .expect("inverse action");
2346
2347        match inverse {
2348            Action::AddClip {
2349                name,
2350                track_name,
2351                start,
2352                length,
2353                offset,
2354                input_channel,
2355                muted,
2356                peaks_file,
2357                kind,
2358                ..
2359            } => {
2360                assert_eq!(name, "audio/clip.wav");
2361                assert_eq!(track_name, "t");
2362                assert_eq!(start, 48);
2363                assert_eq!(length, 96);
2364                assert_eq!(offset, 12);
2365                assert_eq!(input_channel, 0);
2366                assert!(muted);
2367                assert_eq!(peaks_file.as_deref(), Some("peaks/clip.json"));
2368                assert_eq!(kind, Kind::Audio);
2369            }
2370            other => panic!("unexpected inverse action: {other:?}"),
2371        }
2372    }
2373
2374    #[test]
2375    fn create_inverse_action_for_move_clip_to_unused_restores_clip() {
2376        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2377        let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
2378        clip.offset = 12;
2379        track.audio.push_clip(clip);
2380        let state = make_state_with_track(track);
2381
2382        let inverse = create_inverse_action(
2383            &Action::MoveClipToUnused {
2384                track_name: "t".to_string(),
2385                kind: Kind::Audio,
2386                clip_indices: vec![0],
2387            },
2388            &state,
2389        )
2390        .expect("inverse action");
2391
2392        match inverse {
2393            Action::AddClip {
2394                name,
2395                track_name,
2396                start,
2397                length,
2398                offset,
2399                kind,
2400                ..
2401            } => {
2402                assert_eq!(name, "audio/clip.wav");
2403                assert_eq!(track_name, "t");
2404                assert_eq!(start, 48);
2405                assert_eq!(length, 96);
2406                assert_eq!(offset, 12);
2407                assert_eq!(kind, Kind::Audio);
2408            }
2409            other => panic!("unexpected inverse action: {other:?}"),
2410        }
2411    }
2412
2413    #[test]
2414    fn should_record_covers_unused_clip_pool_actions() {
2415        assert!(should_record(&Action::MoveClipToUnused {
2416            track_name: "t".to_string(),
2417            kind: Kind::Audio,
2418            clip_indices: vec![0],
2419        }));
2420        assert!(!should_record(&Action::DeleteUnusedClips {
2421            clip_ids: vec!["clip-1".to_string()],
2422        }));
2423        assert!(!should_record(&Action::SetUnusedClips {
2424            audio: vec![],
2425            midi: vec![],
2426        }));
2427    }
2428
2429    #[test]
2430    fn create_inverse_action_for_remove_grouped_audio_clip_restores_group() {
2431        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2432        let mut group = AudioClip::new("Group".to_string(), 48, 144);
2433        group
2434            .grouped_clips
2435            .push(AudioClip::new("child.wav".to_string(), 0, 32));
2436        track.audio.push_clip(group);
2437        let state = make_state_with_track(track);
2438
2439        let inverse = create_inverse_action(
2440            &Action::RemoveClip {
2441                track_name: "t".to_string(),
2442                kind: Kind::Audio,
2443                clip_indices: vec![0],
2444            },
2445            &state,
2446        )
2447        .expect("inverse action");
2448
2449        match inverse {
2450            Action::AddGroupedClip {
2451                track_name,
2452                kind,
2453                audio_clip,
2454                midi_clip,
2455            } => {
2456                assert_eq!(track_name, "t");
2457                assert_eq!(kind, Kind::Audio);
2458                assert!(midi_clip.is_none());
2459                let audio_clip = audio_clip.expect("audio clip payload");
2460                assert_eq!(audio_clip.name, "Group");
2461                assert_eq!(audio_clip.grouped_clips.len(), 1);
2462                assert_eq!(audio_clip.grouped_clips[0].name, "child.wav");
2463            }
2464            other => panic!("unexpected inverse action: {other:?}"),
2465        }
2466    }
2467
2468    #[test]
2469    fn create_inverse_action_for_remove_midi_clip_restores_clip() {
2470        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2471        track.midi.push_clip(crate::midi::clip::MIDIClip {
2472            name: "pattern.mid".to_string(),
2473            start: 48,
2474            end: 144,
2475            offset: 12,
2476            input_channel: 3,
2477            muted: true,
2478            ..Default::default()
2479        });
2480        let state = make_state_with_track(track);
2481
2482        let inverse = create_inverse_action(
2483            &Action::RemoveClip {
2484                track_name: "t".to_string(),
2485                kind: Kind::MIDI,
2486                clip_indices: vec![0],
2487            },
2488            &state,
2489        )
2490        .expect("inverse action");
2491
2492        match inverse {
2493            Action::AddClip {
2494                name,
2495                track_name,
2496                start,
2497                length,
2498                offset,
2499                input_channel,
2500                muted,
2501                kind,
2502                ..
2503            } => {
2504                assert_eq!(name, "pattern.mid");
2505                assert_eq!(track_name, "t");
2506                assert_eq!(start, 48);
2507                assert_eq!(length, 96);
2508                assert_eq!(offset, 12);
2509                assert_eq!(input_channel, 3);
2510                assert!(muted);
2511                assert_eq!(kind, Kind::MIDI);
2512            }
2513            other => panic!("unexpected inverse action: {other:?}"),
2514        }
2515    }
2516
2517    #[test]
2518    fn create_inverse_action_for_remove_grouped_midi_clip_restores_group() {
2519        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2520        let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
2521        group.grouped_clips.push(crate::midi::clip::MIDIClip::new(
2522            "child.mid".to_string(),
2523            0,
2524            48,
2525        ));
2526        track.midi.push_clip(group);
2527        let state = make_state_with_track(track);
2528
2529        let inverse = create_inverse_action(
2530            &Action::RemoveClip {
2531                track_name: "t".to_string(),
2532                kind: Kind::MIDI,
2533                clip_indices: vec![0],
2534            },
2535            &state,
2536        )
2537        .expect("inverse action");
2538
2539        match inverse {
2540            Action::AddGroupedClip {
2541                track_name,
2542                kind,
2543                audio_clip,
2544                midi_clip,
2545            } => {
2546                assert_eq!(track_name, "t");
2547                assert_eq!(kind, Kind::MIDI);
2548                assert!(audio_clip.is_none());
2549                let midi_clip = midi_clip.expect("midi clip payload");
2550                assert_eq!(midi_clip.name, "Group");
2551                assert_eq!(midi_clip.grouped_clips.len(), 1);
2552                assert_eq!(midi_clip.grouped_clips[0].name, "child.mid");
2553            }
2554            other => panic!("unexpected inverse action: {other:?}"),
2555        }
2556    }
2557
2558    #[test]
2559    fn create_inverse_action_for_remove_grouped_audio_clip_preserves_child_metadata() {
2560        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2561        let mut child = AudioClip::new("child.wav".to_string(), 4, 40);
2562        child.peaks_file = Some("peaks/child.json".to_string());
2563        child.pitch_correction_source_name = Some("source.wav".to_string());
2564        child.pitch_correction_source_offset = Some(8);
2565        child.pitch_correction_source_length = Some(24);
2566        child.pitch_correction_preview_name = Some("preview.wav".to_string());
2567        child.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
2568            start_sample: 1,
2569            length_samples: 2,
2570            detected_midi_pitch: 60.0,
2571            target_midi_pitch: 62.0,
2572            clarity: 0.75,
2573        }];
2574        child.pitch_correction_frame_likeness = Some(0.25);
2575        child.pitch_correction_inertia_ms = Some(100);
2576        child.pitch_correction_formant_compensation = Some(true);
2577        child.plugin_graph_json = Some(serde_json::json!({"plugins":[],"connections":[]}));
2578        let mut group = AudioClip::new("Group".to_string(), 48, 144);
2579        group.grouped_clips.push(child);
2580        track.audio.push_clip(group);
2581        let state = make_state_with_track(track);
2582
2583        let inverse = create_inverse_action(
2584            &Action::RemoveClip {
2585                track_name: "t".to_string(),
2586                kind: Kind::Audio,
2587                clip_indices: vec![0],
2588            },
2589            &state,
2590        )
2591        .expect("inverse action");
2592
2593        match inverse {
2594            Action::AddGroupedClip {
2595                audio_clip: Some(audio_clip),
2596                ..
2597            } => {
2598                let child = &audio_clip.grouped_clips[0];
2599                assert_eq!(child.peaks_file.as_deref(), Some("peaks/child.json"));
2600                assert_eq!(child.source_name.as_deref(), Some("source.wav"));
2601                assert_eq!(child.source_offset, Some(8));
2602                assert_eq!(child.source_length, Some(24));
2603                assert_eq!(child.preview_name.as_deref(), Some("preview.wav"));
2604                assert_eq!(child.pitch_correction_points.len(), 1);
2605                assert_eq!(child.pitch_correction_frame_likeness, Some(0.25));
2606                assert_eq!(child.pitch_correction_inertia_ms, Some(100));
2607                assert_eq!(child.pitch_correction_formant_compensation, Some(true));
2608                assert!(child.plugin_graph_json.is_some());
2609            }
2610            other => panic!("unexpected inverse action: {other:?}"),
2611        }
2612    }
2613
2614    #[test]
2615    fn create_inverse_action_for_remove_grouped_midi_clip_preserves_child_structure() {
2616        let track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
2617        let child = crate::midi::clip::MIDIClip::new("child.mid".to_string(), 0, 48);
2618        let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
2619        group.grouped_clips.push(child);
2620        track.midi.push_clip(group);
2621        let state = make_state_with_track(track);
2622
2623        let inverse = create_inverse_action(
2624            &Action::RemoveClip {
2625                track_name: "t".to_string(),
2626                kind: Kind::MIDI,
2627                clip_indices: vec![0],
2628            },
2629            &state,
2630        )
2631        .expect("inverse action");
2632
2633        match inverse {
2634            Action::AddGroupedClip {
2635                midi_clip: Some(midi_clip),
2636                ..
2637            } => {
2638                let child = &midi_clip.grouped_clips[0];
2639                assert_eq!(child.name, "child.mid");
2640                assert_eq!(child.start, 0);
2641                assert_eq!(child.length, 48);
2642            }
2643            other => panic!("unexpected inverse action: {other:?}"),
2644        }
2645    }
2646
2647    #[test]
2648    fn create_inverse_action_for_set_clip_pitch_correction_restores_previous_values() {
2649        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2650        let mut clip = AudioClip::new("audio.wav".to_string(), 0, 128);
2651        clip.pitch_correction_preview_name = Some("audio_preview.wav".to_string());
2652        clip.pitch_correction_source_name = Some("audio_source.wav".to_string());
2653        clip.pitch_correction_source_offset = Some(12);
2654        clip.pitch_correction_source_length = Some(96);
2655        clip.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
2656            start_sample: 4,
2657            length_samples: 32,
2658            detected_midi_pitch: 60.2,
2659            target_midi_pitch: 61.0,
2660            clarity: 0.8,
2661        }];
2662        clip.pitch_correction_frame_likeness = Some(0.4);
2663        clip.pitch_correction_inertia_ms = Some(250);
2664        clip.pitch_correction_formant_compensation = Some(false);
2665        track.audio.push_clip(clip);
2666        let state = make_state_with_track(track);
2667
2668        let inverse = create_inverse_action(
2669            &Action::SetClipPitchCorrection {
2670                track_name: "t".to_string(),
2671                clip_index: 0,
2672                preview_name: None,
2673                source_name: None,
2674                source_offset: None,
2675                source_length: None,
2676                pitch_correction_points: vec![],
2677                pitch_correction_frame_likeness: None,
2678                pitch_correction_inertia_ms: None,
2679                pitch_correction_formant_compensation: None,
2680            },
2681            &state,
2682        )
2683        .expect("inverse action");
2684
2685        match inverse {
2686            Action::SetClipPitchCorrection {
2687                track_name,
2688                clip_index,
2689                preview_name,
2690                source_name,
2691                source_offset,
2692                source_length,
2693                pitch_correction_points,
2694                pitch_correction_frame_likeness,
2695                pitch_correction_inertia_ms,
2696                pitch_correction_formant_compensation,
2697            } => {
2698                assert_eq!(track_name, "t");
2699                assert_eq!(clip_index, 0);
2700                assert_eq!(preview_name.as_deref(), Some("audio_preview.wav"));
2701                assert_eq!(source_name.as_deref(), Some("audio_source.wav"));
2702                assert_eq!(source_offset, Some(12));
2703                assert_eq!(source_length, Some(96));
2704                assert_eq!(pitch_correction_points.len(), 1);
2705                assert_eq!(pitch_correction_points[0].target_midi_pitch, 61.0);
2706                assert_eq!(pitch_correction_frame_likeness, Some(0.4));
2707                assert_eq!(pitch_correction_inertia_ms, Some(250));
2708                assert_eq!(pitch_correction_formant_compensation, Some(false));
2709            }
2710            other => panic!("unexpected inverse action: {other:?}"),
2711        }
2712    }
2713
2714    #[test]
2715    fn create_inverse_action_for_clip_copy_targets_new_destination_clip() {
2716        let source = Track::new("src".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2717        source
2718            .audio
2719            .push_clip(AudioClip::new("source.wav".to_string(), 12, 48));
2720        let dest = Track::new("dst".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2721        dest.audio
2722            .push_clip(AudioClip::new("existing.wav".to_string(), 0, 24));
2723
2724        let mut state = State::default();
2725        state.tracks.insert(source.name.clone(), Arc::new(source));
2726        state.tracks.insert(dest.name.clone(), Arc::new(dest));
2727
2728        let inverse = create_inverse_action(
2729            &Action::ClipMove {
2730                kind: Kind::Audio,
2731                from: ClipMoveFrom {
2732                    track_name: "src".to_string(),
2733                    clip_index: 0,
2734                },
2735                to: ClipMoveTo {
2736                    track_name: "dst".to_string(),
2737                    sample_offset: 96,
2738                    input_channel: 0,
2739                },
2740                copy: true,
2741            },
2742            &state,
2743        )
2744        .expect("inverse action");
2745
2746        match inverse {
2747            Action::RemoveClip {
2748                track_name,
2749                kind,
2750                clip_indices,
2751            } => {
2752                assert_eq!(track_name, "dst");
2753                assert_eq!(kind, Kind::Audio);
2754                assert_eq!(clip_indices, vec![1]);
2755            }
2756            other => panic!("unexpected inverse action: {other:?}"),
2757        }
2758    }
2759
2760    #[test]
2761    fn create_inverse_action_for_same_track_clip_move_reverses_last_destination_clip() {
2762        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2763        let mut original = AudioClip::new("clip.wav".to_string(), 20, 40);
2764        original.input_channel = 2;
2765        let moved = AudioClip::new("moved.wav".to_string(), 80, 32);
2766        track.audio.push_clip(original);
2767        track.audio.push_clip(moved);
2768        let state = make_state_with_track(track);
2769
2770        let inverse = create_inverse_action(
2771            &Action::ClipMove {
2772                kind: Kind::Audio,
2773                from: ClipMoveFrom {
2774                    track_name: "t".to_string(),
2775                    clip_index: 0,
2776                },
2777                to: ClipMoveTo {
2778                    track_name: "t".to_string(),
2779                    sample_offset: 80,
2780                    input_channel: 1,
2781                },
2782                copy: false,
2783            },
2784            &state,
2785        )
2786        .expect("inverse action");
2787
2788        match inverse {
2789            Action::ClipMove {
2790                kind,
2791                from,
2792                to,
2793                copy,
2794            } => {
2795                assert_eq!(kind, Kind::Audio);
2796                assert_eq!(from.track_name, "t");
2797                assert_eq!(from.clip_index, 1);
2798                assert_eq!(to.track_name, "t");
2799                assert_eq!(to.sample_offset, 20);
2800                assert_eq!(to.input_channel, 2);
2801                assert!(!copy);
2802            }
2803            other => panic!("unexpected inverse action: {other:?}"),
2804        }
2805    }
2806
2807    #[test]
2808    fn create_inverse_action_for_track_midi_binding_restores_previous_binding() {
2809        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2810        track.midi_learn_volume = Some(binding(7));
2811        let state = make_state_with_track(track);
2812
2813        let inverse = create_inverse_action(
2814            &Action::TrackSetMidiLearnBinding {
2815                track_name: "t".to_string(),
2816                target: TrackMidiLearnTarget::Volume,
2817                binding: Some(binding(9)),
2818            },
2819            &state,
2820        )
2821        .unwrap();
2822
2823        match inverse {
2824            Action::TrackSetMidiLearnBinding {
2825                track_name,
2826                target,
2827                binding,
2828            } => {
2829                assert_eq!(track_name, "t");
2830                assert_eq!(target, TrackMidiLearnTarget::Volume);
2831                assert_eq!(binding.unwrap().cc, 7);
2832            }
2833            other => panic!("unexpected inverse action: {other:?}"),
2834        }
2835    }
2836
2837    #[test]
2838    fn create_inverse_action_for_vst3_load_uses_next_runtime_instance_id() {
2839        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2840        track.next_vst3_instance_id.store(42, Ordering::Relaxed);
2841        let state = make_state_with_track(track);
2842
2843        let inverse = create_inverse_action(
2844            &Action::TrackLoadVst3Plugin {
2845                track_name: "t".to_string(),
2846                plugin_id: "/tmp/test.vst3".to_string(),
2847                instance_id: None,
2848            },
2849            &state,
2850        )
2851        .unwrap();
2852
2853        match inverse {
2854            Action::TrackUnloadVst3PluginInstance {
2855                track_name,
2856                instance_id,
2857            } => {
2858                assert_eq!(track_name, "t");
2859                assert_eq!(instance_id, 42);
2860            }
2861            other => panic!("unexpected inverse action: {other:?}"),
2862        }
2863    }
2864
2865    #[test]
2866    #[cfg(all(unix, not(target_os = "macos")))]
2867    fn create_inverse_action_for_lv2_load_uses_next_runtime_instance_id() {
2868        let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2869        track.next_lv2_instance_id.store(5, Ordering::Relaxed);
2870        let state = make_state_with_track(track);
2871
2872        let inverse = create_inverse_action(
2873            &Action::TrackLoadLv2Plugin {
2874                track_name: "t".to_string(),
2875                plugin_uri: "urn:test".to_string(),
2876                instance_id: None,
2877            },
2878            &state,
2879        )
2880        .unwrap();
2881
2882        match inverse {
2883            Action::TrackUnloadLv2PluginInstance {
2884                track_name,
2885                instance_id,
2886            } => {
2887                assert_eq!(track_name, "t");
2888                assert_eq!(instance_id, 5);
2889            }
2890            other => panic!("unexpected inverse action: {other:?}"),
2891        }
2892    }
2893
2894    #[test]
2895    fn create_inverse_actions_for_clear_all_midi_learn_bindings_restores_only_existing_bindings() {
2896        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2897        track.midi_learn_volume = Some(binding(7));
2898        track.midi_learn_disk_monitor = Some(binding(64));
2899        let state = make_state_with_track(track);
2900
2901        let inverses = create_inverse_actions(&Action::ClearAllMidiLearnBindings, &state).unwrap();
2902
2903        assert_eq!(inverses.len(), 2);
2904        assert!(inverses.iter().any(|action| {
2905            matches!(
2906                action,
2907                Action::TrackSetMidiLearnBinding {
2908                    target: TrackMidiLearnTarget::Volume,
2909                    binding: Some(MidiLearnBinding { cc: 7, .. }),
2910                    ..
2911                }
2912            )
2913        }));
2914        assert!(inverses.iter().any(|action| {
2915            matches!(
2916                action,
2917                Action::TrackSetMidiLearnBinding {
2918                    target: TrackMidiLearnTarget::DiskMonitor,
2919                    binding: Some(MidiLearnBinding { cc: 64, .. }),
2920                    ..
2921                }
2922            )
2923        }));
2924    }
2925
2926    #[test]
2927    fn create_inverse_actions_for_remove_track_restores_io_flags_and_bindings() {
2928        let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
2929        track.set_level(-3.0);
2930        track.set_balance(0.25);
2931        track.armed.store(true, Ordering::Relaxed);
2932        track.set_muted(true);
2933        track.soloed.store(true, Ordering::Relaxed);
2934        track.set_input_monitor(vec![true]);
2935        track.set_disk_monitor(vec![false]);
2936        track.midi_learn_volume = Some(binding(10));
2937        track.audio.ins.push(Arc::new(AudioIO::new(64)));
2938        track.audio.outs.push(Arc::new(AudioIO::new(64)));
2939        let state = make_state_with_track(track);
2940
2941        let inverses =
2942            create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
2943
2944        assert!(matches!(
2945            inverses.first(),
2946            Some(Action::AddTrack {
2947                name,
2948                audio_ins: 1,
2949                audio_outs: 1,
2950                midi_ins: 1,
2951                midi_outs: 1,
2952                folder: false,
2953            }) if name == "t"
2954        ));
2955        assert!(
2956            inverses
2957                .iter()
2958                .any(|action| matches!(action, Action::TrackAddAudioInput(name) if name == "t"))
2959        );
2960        assert!(
2961            inverses
2962                .iter()
2963                .any(|action| matches!(action, Action::TrackAddAudioOutput(name) if name == "t"))
2964        );
2965        assert!(
2966            inverses.iter().any(
2967                |action| matches!(action, Action::TrackToggleInputMonitor { track_name, .. } if track_name == "t")
2968            )
2969        );
2970        assert!(
2971            inverses.iter().any(
2972                |action| matches!(action, Action::TrackToggleDiskMonitor { track_name, .. } if track_name == "t")
2973            )
2974        );
2975        assert!(inverses.iter().any(|action| {
2976            matches!(
2977                action,
2978                Action::TrackSetMidiLearnBinding {
2979                    target: TrackMidiLearnTarget::Volume,
2980                    binding: Some(MidiLearnBinding { cc: 10, .. }),
2981                    ..
2982                }
2983            )
2984        }));
2985    }
2986
2987    #[test]
2988    fn create_inverse_actions_for_remove_track_omits_internal_passthrough() {
2989        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
2990        track.ensure_default_audio_passthrough();
2991        track.ensure_default_midi_passthrough();
2992        let state = make_state_with_track(track);
2993
2994        let inverses =
2995            create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
2996
2997        assert!(
2998            !inverses.iter().any(|action| matches!(
2999                action,
3000                Action::Connect {
3001                    from_track,
3002                    to_track,
3003                    ..
3004                } if from_track == to_track
3005            )),
3006            "internal passthrough should not be captured as a track-to-track Connect action"
3007        );
3008    }
3009
3010    #[test]
3011    fn create_inverse_actions_for_remove_folder_track_restores_parent_and_omits_child_wiring() {
3012        let mut state = State::default();
3013        let folder = Track::new_folder("folder".to_string(), 1, 1, 0, 0, 64, 48_000.0);
3014        let child = Track::new("child".to_string(), 1, 1, 0, 0, 64, 48_000.0);
3015        state.tracks.insert("folder".to_string(), Arc::new(folder));
3016        state.tracks.insert("child".to_string(), Arc::new(child));
3017        {
3018            let folder_arc = state.tracks.get("folder").unwrap().clone();
3019            let child_arc = state.tracks.get("child").unwrap().clone();
3020            child_arc.lock().parent_track = Some("folder".to_string());
3021            folder_arc.lock().child_tracks.push(child_arc.clone());
3022
3023            // Simulate the implicit wiring TrackSetParent creates.
3024            let (folder_in, folder_out) = {
3025                let folder = folder_arc.lock();
3026                (folder.audio.ins[0].clone(), folder.audio.outs[0].clone())
3027            };
3028            let (child_in, child_out) = {
3029                let child = child_arc.lock();
3030                (child.audio.ins[0].clone(), child.audio.outs[0].clone())
3031            };
3032            AudioIO::connect(&folder_in, &child_in);
3033            AudioIO::connect(&child_out, &folder_out);
3034        }
3035
3036        let mut inverses =
3037            create_inverse_actions(&Action::RemoveTrack("folder".to_string()), &state).unwrap();
3038        inverses.extend(
3039            create_inverse_actions(&Action::RemoveTrack("child".to_string()), &state).unwrap(),
3040        );
3041
3042        assert!(
3043            inverses.iter().any(|action| matches!(
3044                action,
3045                Action::TrackSetParent {
3046                    track_name,
3047                    parent_name: Some(parent_name),
3048                } if track_name == "child" && parent_name == "folder"
3049            )),
3050            "inverse should restore the child-to-folder parent relationship"
3051        );
3052        assert!(
3053            !inverses.iter().any(|action| matches!(
3054                action,
3055                Action::Connect { from_track, to_track, .. }
3056                if (from_track == "folder" && to_track == "child")
3057                    || (from_track == "child" && to_track == "folder")
3058            )),
3059            "implicit folder-to-child wiring should not be captured as a generic Connect action"
3060        );
3061    }
3062
3063    #[test]
3064    fn create_inverse_action_for_track_set_session_slot_restores_previous_clip() {
3065        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
3066        track
3067            .rt
3068            .session_slots
3069            .insert(0, crate::track::SessionSlot::new("old-clip".to_string()));
3070        let state = make_state_with_track(track);
3071
3072        let inverse = create_inverse_action(
3073            &Action::TrackSetSessionSlot {
3074                track_name: "t".to_string(),
3075                scene_index: 0,
3076                clip_id: Some("new-clip".to_string()),
3077            },
3078            &state,
3079        )
3080        .unwrap();
3081
3082        match inverse {
3083            Action::TrackSetSessionSlot {
3084                track_name,
3085                scene_index,
3086                clip_id,
3087            } => {
3088                assert_eq!(track_name, "t");
3089                assert_eq!(scene_index, 0);
3090                assert_eq!(clip_id, Some("old-clip".to_string()));
3091            }
3092            other => panic!("unexpected inverse action: {other:?}"),
3093        }
3094    }
3095
3096    #[test]
3097    fn create_inverse_action_for_track_set_session_slot_clear_restores_none() {
3098        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
3099        track
3100            .rt
3101            .session_slots
3102            .insert(0, crate::track::SessionSlot::new("old-clip".to_string()));
3103        let state = make_state_with_track(track);
3104
3105        let inverse = create_inverse_action(
3106            &Action::TrackSetSessionSlot {
3107                track_name: "t".to_string(),
3108                scene_index: 0,
3109                clip_id: None,
3110            },
3111            &state,
3112        )
3113        .unwrap();
3114
3115        match inverse {
3116            Action::TrackSetSessionSlot {
3117                track_name,
3118                scene_index,
3119                clip_id,
3120            } => {
3121                assert_eq!(track_name, "t");
3122                assert_eq!(scene_index, 0);
3123                assert_eq!(clip_id, Some("old-clip".to_string()));
3124            }
3125            other => panic!("unexpected inverse action: {other:?}"),
3126        }
3127    }
3128
3129    #[test]
3130    fn create_inverse_action_for_track_set_session_slot_play_enabled_restores_previous_flag() {
3131        let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
3132        track
3133            .rt
3134            .session_slots
3135            .insert(0, crate::track::SessionSlot::new("clip".to_string()));
3136        track.rt.session_slots.get_mut(&0).unwrap().play_enabled = false;
3137        let state = make_state_with_track(track);
3138
3139        let inverse = create_inverse_action(
3140            &Action::TrackSetSessionSlotPlayEnabled {
3141                track_name: "t".to_string(),
3142                scene_index: 0,
3143                enabled: true,
3144            },
3145            &state,
3146        )
3147        .unwrap();
3148
3149        match inverse {
3150            Action::TrackSetSessionSlotPlayEnabled {
3151                track_name,
3152                scene_index,
3153                enabled,
3154            } => {
3155                assert_eq!(track_name, "t");
3156                assert_eq!(scene_index, 0);
3157                assert!(!enabled);
3158            }
3159            other => panic!("unexpected inverse action: {other:?}"),
3160        }
3161    }
3162}