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