Skip to main content

maolan_engine/
history.rs

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