use crate::{
audio::io::AudioIO,
kind::Kind,
message::{Action, ClipMoveFrom, ClipMoveTo},
midi::io::MIDIIO,
state::State,
};
use std::collections::VecDeque;
use std::sync::Arc;
fn audio_clip_to_data(clip: &crate::audio::clip::AudioClip) -> crate::message::AudioClipData {
crate::message::AudioClipData {
name: clip.name.clone(),
start: clip.start,
length: clip.end.saturating_sub(clip.start).max(1),
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
peaks_file: clip.peaks_file.clone(),
fade_enabled: clip.fade_enabled,
fade_in_samples: clip.fade_in_samples,
fade_out_samples: clip.fade_out_samples,
preview_name: clip.pitch_correction_preview_name.clone(),
source_name: clip.pitch_correction_source_name.clone(),
source_offset: clip.pitch_correction_source_offset,
source_length: clip.pitch_correction_source_length,
pitch_correction_points: clip.pitch_correction_points.clone(),
pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
plugin_graph_json: clip.plugin_graph_json.clone(),
grouped_clips: clip.grouped_clips.iter().map(audio_clip_to_data).collect(),
}
}
fn midi_clip_to_data(clip: &crate::midi::clip::MIDIClip) -> crate::message::MidiClipData {
crate::message::MidiClipData {
name: clip.name.clone(),
start: clip.start,
length: clip.end.saturating_sub(clip.start).max(1),
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
grouped_clips: clip.grouped_clips.iter().map(midi_clip_to_data).collect(),
}
}
#[derive(Clone, Debug)]
pub struct UndoEntry {
pub forward_actions: Vec<Action>,
pub inverse_actions: Vec<Action>,
}
pub struct History {
undo_stack: VecDeque<UndoEntry>,
redo_stack: VecDeque<UndoEntry>,
max_history: usize,
}
impl History {
pub fn new(max_history: usize) -> Self {
Self {
undo_stack: VecDeque::new(),
redo_stack: VecDeque::new(),
max_history,
}
}
pub fn record(&mut self, entry: UndoEntry) {
self.undo_stack.push_back(entry);
self.redo_stack.clear();
if self.undo_stack.len() > self.max_history {
self.undo_stack.pop_front();
}
}
pub fn undo(&mut self) -> Option<Vec<Action>> {
self.undo_stack.pop_back().map(|entry| {
let inverse = entry.inverse_actions.clone();
self.redo_stack.push_back(entry);
inverse
})
}
pub fn redo(&mut self) -> Option<Vec<Action>> {
self.redo_stack.pop_back().map(|entry| {
let forward = entry.forward_actions.clone();
self.undo_stack.push_back(entry);
forward
})
}
pub fn clear(&mut self) {
self.undo_stack.clear();
self.redo_stack.clear();
}
}
impl Default for History {
fn default() -> Self {
Self::new(100)
}
}
pub fn should_record(action: &Action) -> bool {
matches!(
action,
Action::SetTempo(_)
| Action::SetLoopEnabled(_)
| Action::SetLoopRange(_)
| Action::SetPunchEnabled(_)
| Action::SetPunchRange(_)
| Action::SetMetronomeEnabled(_)
| Action::SetTimeSignature { .. }
| Action::AddTrack { .. }
| Action::RemoveTrack(_)
| Action::RenameTrack { .. }
| Action::TrackLevel(_, _)
| Action::TrackBalance(_, _)
| Action::TrackToggleArm(_)
| Action::TrackToggleMute(_)
| Action::TrackToggleSolo(_)
| Action::TrackToggleInputMonitor(_)
| Action::TrackToggleDiskMonitor(_)
| Action::TrackSetMidiLearnBinding { .. }
| Action::SetGlobalMidiLearnBinding { .. }
| Action::TrackSetVcaMaster { .. }
| Action::TrackSetFrozen { .. }
| Action::TrackAddAudioInput(_)
| Action::TrackAddAudioOutput(_)
| Action::TrackRemoveAudioInput(_)
| Action::TrackRemoveAudioOutput(_)
| Action::AddClip { .. }
| Action::AddGroupedClip { .. }
| Action::RemoveClip { .. }
| Action::RenameClip { .. }
| Action::ClipMove { .. }
| Action::SetClipFade { .. }
| Action::SetClipBounds { .. }
| Action::SetClipMuted { .. }
| Action::ClearAllMidiLearnBindings
| Action::Connect { .. }
| Action::Disconnect { .. }
| Action::TrackConnectVst3Audio { .. }
| Action::TrackDisconnectVst3Audio { .. }
| Action::TrackConnectPluginAudio { .. }
| Action::TrackDisconnectPluginAudio { .. }
| Action::TrackConnectPluginMidi { .. }
| Action::TrackDisconnectPluginMidi { .. }
| Action::TrackLoadClapPlugin { .. }
| Action::TrackUnloadClapPlugin { .. }
| Action::TrackLoadLv2Plugin { .. }
| Action::TrackUnloadLv2PluginInstance { .. }
| Action::TrackLoadVst3Plugin { .. }
| Action::TrackUnloadVst3PluginInstance { .. }
| Action::TrackSetLv2ControlValue { .. }
| Action::TrackSetClapParameter { .. }
| Action::TrackSetVst3Parameter { .. }
| Action::ModifyMidiNotes { .. }
| Action::ModifyMidiControllers { .. }
| Action::DeleteMidiControllers { .. }
| Action::InsertMidiControllers { .. }
| Action::DeleteMidiNotes { .. }
| Action::InsertMidiNotes { .. }
| Action::SetMidiSysExEvents { .. }
)
}
pub fn create_inverse_action(action: &Action, state: &State) -> Option<Action> {
match action {
Action::AddTrack { name, .. } => Some(Action::RemoveTrack(name.clone())),
Action::RemoveTrack(name) => {
let track = state.tracks.get(name)?;
let track_lock = track.lock();
Some(Action::AddTrack {
name: track_lock.name.clone(),
audio_ins: track_lock.primary_audio_ins(),
midi_ins: track_lock.midi.ins.len(),
audio_outs: track_lock.primary_audio_outs(),
midi_outs: track_lock.midi.outs.len(),
})
}
Action::RenameTrack { old_name, new_name } => Some(Action::RenameTrack {
old_name: new_name.clone(),
new_name: old_name.clone(),
}),
Action::TrackLevel(name, _new_level) => {
let track = state.tracks.get(name)?;
let track_lock = track.lock();
Some(Action::TrackLevel(name.clone(), track_lock.level))
}
Action::TrackBalance(name, _new_balance) => {
let track = state.tracks.get(name)?;
let track_lock = track.lock();
Some(Action::TrackBalance(name.clone(), track_lock.balance))
}
Action::TrackToggleArm(name) => Some(Action::TrackToggleArm(name.clone())),
Action::TrackToggleMute(name) => Some(Action::TrackToggleMute(name.clone())),
Action::TrackToggleSolo(name) => Some(Action::TrackToggleSolo(name.clone())),
Action::TrackToggleInputMonitor(name) => {
Some(Action::TrackToggleInputMonitor(name.clone()))
}
Action::TrackToggleDiskMonitor(name) => Some(Action::TrackToggleDiskMonitor(name.clone())),
Action::TrackSetMidiLearnBinding {
track_name, target, ..
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
let binding = match target {
crate::message::TrackMidiLearnTarget::Volume => {
track_lock.midi_learn_volume.clone()
}
crate::message::TrackMidiLearnTarget::Balance => {
track_lock.midi_learn_balance.clone()
}
crate::message::TrackMidiLearnTarget::Mute => track_lock.midi_learn_mute.clone(),
crate::message::TrackMidiLearnTarget::Solo => track_lock.midi_learn_solo.clone(),
crate::message::TrackMidiLearnTarget::Arm => track_lock.midi_learn_arm.clone(),
crate::message::TrackMidiLearnTarget::InputMonitor => {
track_lock.midi_learn_input_monitor.clone()
}
crate::message::TrackMidiLearnTarget::DiskMonitor => {
track_lock.midi_learn_disk_monitor.clone()
}
};
Some(Action::TrackSetMidiLearnBinding {
track_name: track_name.clone(),
target: *target,
binding,
})
}
Action::TrackSetVcaMaster { track_name, .. } => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
Some(Action::TrackSetVcaMaster {
track_name: track_name.clone(),
master_track: track_lock.vca_master(),
})
}
Action::TrackSetFrozen { track_name, .. } => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
Some(Action::TrackSetFrozen {
track_name: track_name.clone(),
frozen: track_lock.frozen(),
})
}
Action::TrackAddAudioInput(name) => Some(Action::TrackRemoveAudioInput(name.clone())),
Action::TrackAddAudioOutput(name) => Some(Action::TrackRemoveAudioOutput(name.clone())),
Action::TrackRemoveAudioInput(name) => Some(Action::TrackAddAudioInput(name.clone())),
Action::TrackRemoveAudioOutput(name) => Some(Action::TrackAddAudioOutput(name.clone())),
Action::AddClip {
track_name, kind, ..
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
let clip_index = match kind {
Kind::Audio => track_lock.audio.clips.len(),
Kind::MIDI => track_lock.midi.clips.len(),
};
Some(Action::RemoveClip {
track_name: track_name.clone(),
kind: *kind,
clip_indices: vec![clip_index],
})
}
Action::AddGroupedClip {
track_name, kind, ..
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
let clip_index = match kind {
Kind::Audio => track_lock.audio.clips.len(),
Kind::MIDI => track_lock.midi.clips.len(),
};
Some(Action::RemoveClip {
track_name: track_name.clone(),
kind: *kind,
clip_indices: vec![clip_index],
})
}
Action::RemoveClip {
track_name,
kind,
clip_indices,
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
if clip_indices.len() != 1 {
return None;
}
let clip_idx = clip_indices[0];
match kind {
Kind::Audio => {
let clip = track_lock.audio.clips.get(clip_idx)?;
if clip.grouped_clips.is_empty() {
let length = clip.end.saturating_sub(clip.start);
Some(Action::AddClip {
name: clip.name.clone(),
track_name: track_name.clone(),
start: clip.start,
length,
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
peaks_file: clip.peaks_file.clone(),
kind: Kind::Audio,
fade_enabled: clip.fade_enabled,
fade_in_samples: clip.fade_in_samples,
fade_out_samples: clip.fade_out_samples,
source_name: clip.pitch_correction_source_name.clone(),
source_offset: clip.pitch_correction_source_offset,
source_length: clip.pitch_correction_source_length,
preview_name: clip.pitch_correction_preview_name.clone(),
pitch_correction_points: clip.pitch_correction_points.clone(),
pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
pitch_correction_formant_compensation: clip
.pitch_correction_formant_compensation,
plugin_graph_json: clip.plugin_graph_json.clone(),
})
} else {
Some(Action::AddGroupedClip {
track_name: track_name.clone(),
kind: Kind::Audio,
audio_clip: Some(audio_clip_to_data(clip)),
midi_clip: None,
})
}
}
Kind::MIDI => {
let clip = track_lock.midi.clips.get(clip_idx)?;
if clip.grouped_clips.is_empty() {
let length = clip.end.saturating_sub(clip.start);
Some(Action::AddClip {
name: clip.name.clone(),
track_name: track_name.clone(),
start: clip.start,
length,
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
peaks_file: None,
kind: Kind::MIDI,
fade_enabled: true,
fade_in_samples: 240,
fade_out_samples: 240,
source_name: None,
source_offset: None,
source_length: None,
preview_name: None,
pitch_correction_points: vec![],
pitch_correction_frame_likeness: None,
pitch_correction_inertia_ms: None,
pitch_correction_formant_compensation: None,
plugin_graph_json: None,
})
} else {
Some(Action::AddGroupedClip {
track_name: track_name.clone(),
kind: Kind::MIDI,
audio_clip: None,
midi_clip: Some(midi_clip_to_data(clip)),
})
}
}
}
}
Action::RenameClip {
track_name,
kind,
clip_index,
new_name: _,
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
let old_name = match kind {
Kind::Audio => track_lock.audio.clips.get(*clip_index)?.name.clone(),
Kind::MIDI => track_lock.midi.clips.get(*clip_index)?.name.clone(),
};
Some(Action::RenameClip {
track_name: track_name.clone(),
kind: *kind,
clip_index: *clip_index,
new_name: old_name,
})
}
Action::ClipMove {
kind,
from,
to,
copy,
} => {
let (original_start, original_input_channel) = {
let source_track = state.tracks.get(&from.track_name)?;
let source_lock = source_track.lock();
match kind {
Kind::Audio => {
let clip = source_lock.audio.clips.get(from.clip_index)?;
(clip.start, clip.input_channel)
}
Kind::MIDI => {
let clip = source_lock.midi.clips.get(from.clip_index)?;
(clip.start, clip.input_channel)
}
}
};
if *copy {
let dest_track = state.tracks.get(&to.track_name)?;
let dest_lock = dest_track.lock();
let clip_idx = match kind {
Kind::Audio => dest_lock.audio.clips.len(),
Kind::MIDI => dest_lock.midi.clips.len(),
};
Some(Action::RemoveClip {
track_name: to.track_name.clone(),
kind: *kind,
clip_indices: vec![clip_idx],
})
} else {
let dest_track = state.tracks.get(&to.track_name)?;
let dest_lock = dest_track.lock();
let dest_len = match kind {
Kind::Audio => {
if dest_lock.audio.clips.is_empty() {
return None;
}
dest_lock.audio.clips.len()
}
Kind::MIDI => {
if dest_lock.midi.clips.is_empty() {
return None;
}
dest_lock.midi.clips.len()
}
};
let moved_clip_index = if from.track_name == to.track_name {
dest_len.saturating_sub(1)
} else {
dest_len
};
Some(Action::ClipMove {
kind: *kind,
from: ClipMoveFrom {
track_name: to.track_name.clone(),
clip_index: moved_clip_index,
},
to: ClipMoveTo {
track_name: from.track_name.clone(),
sample_offset: original_start,
input_channel: original_input_channel,
},
copy: false,
})
}
}
Action::SetClipFade {
track_name,
clip_index,
kind,
..
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
match kind {
Kind::Audio => {
let clip = track_lock.audio.clips.get(*clip_index)?;
Some(Action::SetClipFade {
track_name: track_name.clone(),
clip_index: *clip_index,
kind: *kind,
fade_enabled: clip.fade_enabled,
fade_in_samples: clip.fade_in_samples,
fade_out_samples: clip.fade_out_samples,
})
}
Kind::MIDI => {
Some(Action::SetClipFade {
track_name: track_name.clone(),
clip_index: *clip_index,
kind: *kind,
fade_enabled: true,
fade_in_samples: 240,
fade_out_samples: 240,
})
}
}
}
Action::SetClipBounds {
track_name,
clip_index,
kind,
..
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
match kind {
Kind::Audio => {
let clip = track_lock.audio.clips.get(*clip_index)?;
Some(Action::SetClipBounds {
track_name: track_name.clone(),
clip_index: *clip_index,
kind: *kind,
start: clip.start,
length: clip.end.max(1),
offset: clip.offset,
})
}
Kind::MIDI => {
let clip = track_lock.midi.clips.get(*clip_index)?;
Some(Action::SetClipBounds {
track_name: track_name.clone(),
clip_index: *clip_index,
kind: *kind,
start: clip.start,
length: clip.end.max(1),
offset: clip.offset,
})
}
}
}
Action::SetClipMuted {
track_name,
clip_index,
kind,
..
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
let muted = match kind {
Kind::Audio => track_lock.audio.clips.get(*clip_index)?.muted,
Kind::MIDI => track_lock.midi.clips.get(*clip_index)?.muted,
};
Some(Action::SetClipMuted {
track_name: track_name.clone(),
clip_index: *clip_index,
kind: *kind,
muted,
})
}
Action::SetClipPitchCorrection {
track_name,
clip_index,
..
} => {
let track = state.tracks.get(track_name)?;
let track_lock = track.lock();
let clip = track_lock.audio.clips.get(*clip_index)?;
Some(Action::SetClipPitchCorrection {
track_name: track_name.clone(),
clip_index: *clip_index,
preview_name: clip.pitch_correction_preview_name.clone(),
source_name: clip.pitch_correction_source_name.clone(),
source_offset: clip.pitch_correction_source_offset,
source_length: clip.pitch_correction_source_length,
pitch_correction_points: clip.pitch_correction_points.clone(),
pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
pitch_correction_formant_compensation: clip.pitch_correction_formant_compensation,
})
}
Action::Connect {
from_track,
from_port,
to_track,
to_port,
kind,
} => Some(Action::Disconnect {
from_track: from_track.clone(),
from_port: *from_port,
to_track: to_track.clone(),
to_port: *to_port,
kind: *kind,
}),
Action::Disconnect {
from_track,
from_port,
to_track,
to_port,
kind,
} => Some(Action::Connect {
from_track: from_track.clone(),
from_port: *from_port,
to_track: to_track.clone(),
to_port: *to_port,
kind: *kind,
}),
Action::TrackConnectVst3Audio {
track_name,
from_node,
from_port,
to_node,
to_port,
} => Some(Action::TrackDisconnectVst3Audio {
track_name: track_name.clone(),
from_node: from_node.clone(),
from_port: *from_port,
to_node: to_node.clone(),
to_port: *to_port,
}),
Action::TrackDisconnectVst3Audio {
track_name,
from_node,
from_port,
to_node,
to_port,
} => Some(Action::TrackConnectVst3Audio {
track_name: track_name.clone(),
from_node: from_node.clone(),
from_port: *from_port,
to_node: to_node.clone(),
to_port: *to_port,
}),
Action::TrackConnectPluginAudio {
track_name,
from_node,
from_port,
to_node,
to_port,
} => Some(Action::TrackDisconnectPluginAudio {
track_name: track_name.clone(),
from_node: from_node.clone(),
from_port: *from_port,
to_node: to_node.clone(),
to_port: *to_port,
}),
Action::TrackDisconnectPluginAudio {
track_name,
from_node,
from_port,
to_node,
to_port,
} => Some(Action::TrackConnectPluginAudio {
track_name: track_name.clone(),
from_node: from_node.clone(),
from_port: *from_port,
to_node: to_node.clone(),
to_port: *to_port,
}),
Action::TrackConnectPluginMidi {
track_name,
from_node,
from_port,
to_node,
to_port,
} => Some(Action::TrackDisconnectPluginMidi {
track_name: track_name.clone(),
from_node: from_node.clone(),
from_port: *from_port,
to_node: to_node.clone(),
to_port: *to_port,
}),
Action::TrackDisconnectPluginMidi {
track_name,
from_node,
from_port,
to_node,
to_port,
} => Some(Action::TrackConnectPluginMidi {
track_name: track_name.clone(),
from_node: from_node.clone(),
from_port: *from_port,
to_node: to_node.clone(),
to_port: *to_port,
}),
Action::TrackLoadClapPlugin {
track_name,
plugin_path,
} => Some(Action::TrackUnloadClapPlugin {
track_name: track_name.clone(),
plugin_path: plugin_path.clone(),
}),
Action::TrackUnloadClapPlugin {
track_name,
plugin_path,
} => Some(Action::TrackLoadClapPlugin {
track_name: track_name.clone(),
plugin_path: plugin_path.clone(),
}),
Action::TrackLoadLv2Plugin {
track_name,
plugin_uri: _,
} => {
let track = state.tracks.get(track_name)?;
let track = track.lock();
Some(Action::TrackUnloadLv2PluginInstance {
track_name: track_name.clone(),
instance_id: track.next_lv2_instance_id,
})
}
Action::TrackUnloadLv2PluginInstance {
track_name,
instance_id,
} => {
let track = state.tracks.get(track_name)?;
let track = track.lock();
let plugin_uri = track
.loaded_lv2_instances()
.into_iter()
.find(|(id, _)| *id == *instance_id)
.map(|(_, uri)| uri)?;
Some(Action::TrackLoadLv2Plugin {
track_name: track_name.clone(),
plugin_uri,
})
}
Action::TrackLoadVst3Plugin {
track_name,
plugin_path: _,
} => {
let track = state.tracks.get(track_name)?;
let track = track.lock();
Some(Action::TrackUnloadVst3PluginInstance {
track_name: track_name.clone(),
instance_id: track.next_plugin_instance_id,
})
}
Action::TrackUnloadVst3PluginInstance {
track_name,
instance_id,
} => {
let track = state.tracks.get(track_name)?;
let track = track.lock();
let plugin_path = track
.loaded_vst3_instances()
.into_iter()
.find(|(id, _, _)| *id == *instance_id)
.map(|(_, path, _)| path)?;
Some(Action::TrackLoadVst3Plugin {
track_name: track_name.clone(),
plugin_path,
})
}
Action::TrackSetClapParameter {
track_name,
instance_id,
..
} => {
let track = state.tracks.get(track_name)?;
let track = track.lock();
let snapshot = track.clap_snapshot_state(*instance_id).ok()?;
Some(Action::TrackClapRestoreState {
track_name: track_name.clone(),
instance_id: *instance_id,
state: snapshot,
})
}
Action::TrackSetVst3Parameter {
track_name,
instance_id,
..
} => {
let track = state.tracks.get(track_name)?;
let track = track.lock();
let snapshot = track.vst3_snapshot_state(*instance_id).ok()?;
Some(Action::TrackVst3RestoreState {
track_name: track_name.clone(),
instance_id: *instance_id,
state: snapshot,
})
}
Action::TrackSetLv2ControlValue {
track_name,
instance_id,
..
} => {
let track = state.tracks.get(track_name)?;
let track = track.lock();
let snapshot = track.lv2_snapshot_state(*instance_id).ok()?;
Some(Action::TrackSetLv2PluginState {
track_name: track_name.clone(),
instance_id: *instance_id,
state: snapshot,
})
}
Action::ModifyMidiNotes {
track_name,
clip_index,
note_indices,
new_notes,
old_notes,
} => Some(Action::ModifyMidiNotes {
track_name: track_name.clone(),
clip_index: *clip_index,
note_indices: note_indices.clone(),
new_notes: old_notes.clone(),
old_notes: new_notes.clone(),
}),
Action::ModifyMidiControllers {
track_name,
clip_index,
controller_indices,
new_controllers,
old_controllers,
} => Some(Action::ModifyMidiControllers {
track_name: track_name.clone(),
clip_index: *clip_index,
controller_indices: controller_indices.clone(),
new_controllers: old_controllers.clone(),
old_controllers: new_controllers.clone(),
}),
Action::DeleteMidiControllers {
track_name,
clip_index,
deleted_controllers,
..
} => Some(Action::InsertMidiControllers {
track_name: track_name.clone(),
clip_index: *clip_index,
controllers: deleted_controllers.clone(),
}),
Action::InsertMidiControllers {
track_name,
clip_index,
controllers,
} => {
let mut controller_indices: Vec<usize> =
controllers.iter().map(|(idx, _)| *idx).collect();
controller_indices.sort_unstable_by(|a, b| b.cmp(a));
Some(Action::DeleteMidiControllers {
track_name: track_name.clone(),
clip_index: *clip_index,
controller_indices,
deleted_controllers: controllers.clone(),
})
}
Action::DeleteMidiNotes {
track_name,
clip_index,
deleted_notes,
..
} => Some(Action::InsertMidiNotes {
track_name: track_name.clone(),
clip_index: *clip_index,
notes: deleted_notes.clone(),
}),
Action::InsertMidiNotes {
track_name,
clip_index,
notes,
} => {
let mut note_indices: Vec<usize> = notes.iter().map(|(idx, _)| *idx).collect();
note_indices.sort_unstable_by(|a, b| b.cmp(a));
Some(Action::DeleteMidiNotes {
track_name: track_name.clone(),
clip_index: *clip_index,
note_indices,
deleted_notes: notes.clone(),
})
}
Action::SetMidiSysExEvents {
track_name,
clip_index,
new_sysex_events,
old_sysex_events,
} => Some(Action::SetMidiSysExEvents {
track_name: track_name.clone(),
clip_index: *clip_index,
new_sysex_events: old_sysex_events.clone(),
old_sysex_events: new_sysex_events.clone(),
}),
_ => None,
}
}
pub fn create_inverse_actions(action: &Action, state: &State) -> Option<Vec<Action>> {
if let Action::ClearAllMidiLearnBindings = action {
let mut actions = Vec::<Action>::new();
for (track_name, track) in &state.tracks {
let t = track.lock();
let mut push_if_some =
|target: crate::message::TrackMidiLearnTarget,
binding: Option<crate::message::MidiLearnBinding>| {
if binding.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track_name.clone(),
target,
binding,
});
}
};
push_if_some(
crate::message::TrackMidiLearnTarget::Volume,
t.midi_learn_volume.clone(),
);
push_if_some(
crate::message::TrackMidiLearnTarget::Balance,
t.midi_learn_balance.clone(),
);
push_if_some(
crate::message::TrackMidiLearnTarget::Mute,
t.midi_learn_mute.clone(),
);
push_if_some(
crate::message::TrackMidiLearnTarget::Solo,
t.midi_learn_solo.clone(),
);
push_if_some(
crate::message::TrackMidiLearnTarget::Arm,
t.midi_learn_arm.clone(),
);
push_if_some(
crate::message::TrackMidiLearnTarget::InputMonitor,
t.midi_learn_input_monitor.clone(),
);
push_if_some(
crate::message::TrackMidiLearnTarget::DiskMonitor,
t.midi_learn_disk_monitor.clone(),
);
}
return Some(actions);
}
if let Action::RemoveTrack(track_name) = action {
let mut actions = Vec::new();
{
let track = state.tracks.get(track_name)?;
let track = track.lock();
actions.push(Action::AddTrack {
name: track.name.clone(),
audio_ins: track.primary_audio_ins(),
midi_ins: track.midi.ins.len(),
audio_outs: track.primary_audio_outs(),
midi_outs: track.midi.outs.len(),
});
for _ in track.primary_audio_ins()..track.audio.ins.len() {
actions.push(Action::TrackAddAudioInput(track.name.clone()));
}
for _ in track.primary_audio_outs()..track.audio.outs.len() {
actions.push(Action::TrackAddAudioOutput(track.name.clone()));
}
if track.level != 0.0 {
actions.push(Action::TrackLevel(track.name.clone(), track.level));
}
if track.balance != 0.0 {
actions.push(Action::TrackBalance(track.name.clone(), track.balance));
}
if track.armed {
actions.push(Action::TrackToggleArm(track.name.clone()));
}
if track.muted {
actions.push(Action::TrackToggleMute(track.name.clone()));
}
if track.soloed {
actions.push(Action::TrackToggleSolo(track.name.clone()));
}
if track.input_monitor {
actions.push(Action::TrackToggleInputMonitor(track.name.clone()));
}
if !track.disk_monitor {
actions.push(Action::TrackToggleDiskMonitor(track.name.clone()));
}
if track.midi_learn_volume.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track.name.clone(),
target: crate::message::TrackMidiLearnTarget::Volume,
binding: track.midi_learn_volume.clone(),
});
}
if track.midi_learn_balance.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track.name.clone(),
target: crate::message::TrackMidiLearnTarget::Balance,
binding: track.midi_learn_balance.clone(),
});
}
if track.midi_learn_mute.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track.name.clone(),
target: crate::message::TrackMidiLearnTarget::Mute,
binding: track.midi_learn_mute.clone(),
});
}
if track.midi_learn_solo.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track.name.clone(),
target: crate::message::TrackMidiLearnTarget::Solo,
binding: track.midi_learn_solo.clone(),
});
}
if track.midi_learn_arm.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track.name.clone(),
target: crate::message::TrackMidiLearnTarget::Arm,
binding: track.midi_learn_arm.clone(),
});
}
if track.midi_learn_input_monitor.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track.name.clone(),
target: crate::message::TrackMidiLearnTarget::InputMonitor,
binding: track.midi_learn_input_monitor.clone(),
});
}
if track.midi_learn_disk_monitor.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track.name.clone(),
target: crate::message::TrackMidiLearnTarget::DiskMonitor,
binding: track.midi_learn_disk_monitor.clone(),
});
}
if track.vca_master.is_some() {
actions.push(Action::TrackSetVcaMaster {
track_name: track.name.clone(),
master_track: track.vca_master(),
});
}
for (other_name, other_track_handle) in &state.tracks {
if other_name == track_name {
continue;
}
let other_track = other_track_handle.lock();
if other_track.vca_master.as_deref() == Some(track_name.as_str()) {
actions.push(Action::TrackSetVcaMaster {
track_name: other_name.clone(),
master_track: Some(track_name.clone()),
});
}
}
for clip in &track.audio.clips {
let length = clip.end.saturating_sub(clip.start).max(1);
actions.push(Action::AddClip {
name: clip.name.clone(),
track_name: track.name.clone(),
start: clip.start,
length,
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
peaks_file: clip.peaks_file.clone(),
kind: Kind::Audio,
fade_enabled: clip.fade_enabled,
fade_in_samples: clip.fade_in_samples,
fade_out_samples: clip.fade_out_samples,
source_name: clip.pitch_correction_source_name.clone(),
source_offset: clip.pitch_correction_source_offset,
source_length: clip.pitch_correction_source_length,
preview_name: clip.pitch_correction_preview_name.clone(),
pitch_correction_points: clip.pitch_correction_points.clone(),
pitch_correction_frame_likeness: clip.pitch_correction_frame_likeness,
pitch_correction_inertia_ms: clip.pitch_correction_inertia_ms,
pitch_correction_formant_compensation: clip
.pitch_correction_formant_compensation,
plugin_graph_json: clip.plugin_graph_json.clone(),
});
}
for clip in &track.midi.clips {
let length = clip.end.saturating_sub(clip.start).max(1);
actions.push(Action::AddClip {
name: clip.name.clone(),
track_name: track.name.clone(),
start: clip.start,
length,
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
peaks_file: None,
kind: Kind::MIDI,
fade_enabled: true,
fade_in_samples: 240,
fade_out_samples: 240,
source_name: None,
source_offset: None,
source_length: None,
preview_name: None,
pitch_correction_points: vec![],
pitch_correction_frame_likeness: None,
pitch_correction_inertia_ms: None,
pitch_correction_formant_compensation: None,
plugin_graph_json: None,
});
}
}
let mut seen_audio = std::collections::HashSet::<(String, usize, String, usize)>::new();
let mut seen_midi = std::collections::HashSet::<(String, usize, String, usize)>::new();
for (from_name, from_track_handle) in &state.tracks {
let from_track = from_track_handle.lock();
for (from_port, out) in from_track.audio.outs.iter().enumerate() {
let conns: Vec<Arc<AudioIO>> = out.connections.lock().to_vec();
for conn in conns {
for (to_name, to_track_handle) in &state.tracks {
let to_track = to_track_handle.lock();
for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
if Arc::ptr_eq(&conn, to_in)
&& (from_name == track_name || to_name == track_name)
&& seen_audio.insert((
from_name.clone(),
from_port,
to_name.clone(),
to_port,
))
{
actions.push(Action::Connect {
from_track: from_name.clone(),
from_port,
to_track: to_name.clone(),
to_port,
kind: Kind::Audio,
});
}
}
}
}
}
for (from_port, out) in from_track.midi.outs.iter().enumerate() {
let conns: Vec<Arc<crate::mutex::UnsafeMutex<Box<MIDIIO>>>> =
out.lock().connections.to_vec();
for conn in conns {
for (to_name, to_track_handle) in &state.tracks {
let to_track = to_track_handle.lock();
for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
if Arc::ptr_eq(&conn, to_in)
&& (from_name == track_name || to_name == track_name)
&& seen_midi.insert((
from_name.clone(),
from_port,
to_name.clone(),
to_port,
))
{
actions.push(Action::Connect {
from_track: from_name.clone(),
from_port,
to_track: to_name.clone(),
to_port,
kind: Kind::MIDI,
});
}
}
}
}
}
}
for (to_name, to_track_handle) in &state.tracks {
if to_name != track_name {
continue;
}
let to_track = to_track_handle.lock();
for (to_port, to_in) in to_track.audio.ins.iter().enumerate() {
for (from_name, from_track_handle) in &state.tracks {
let from_track = from_track_handle.lock();
for (from_port, out) in from_track.audio.outs.iter().enumerate() {
let conns: Vec<Arc<AudioIO>> = out.connections.lock().to_vec();
if conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
&& seen_audio.insert((
from_name.clone(),
from_port,
to_name.clone(),
to_port,
))
{
actions.push(Action::Connect {
from_track: from_name.clone(),
from_port,
to_track: to_name.clone(),
to_port,
kind: Kind::Audio,
});
}
}
}
}
for (to_port, to_in) in to_track.midi.ins.iter().enumerate() {
for (from_name, from_track_handle) in &state.tracks {
let from_track = from_track_handle.lock();
for (from_port, out) in from_track.midi.outs.iter().enumerate() {
let conns: Vec<Arc<crate::mutex::UnsafeMutex<Box<MIDIIO>>>> =
out.lock().connections.to_vec();
if conns.iter().any(|conn| Arc::ptr_eq(conn, to_in))
&& seen_midi.insert((
from_name.clone(),
from_port,
to_name.clone(),
to_port,
))
{
actions.push(Action::Connect {
from_track: from_name.clone(),
from_port,
to_track: to_name.clone(),
to_port,
kind: Kind::MIDI,
});
}
}
}
}
}
return Some(actions);
}
create_inverse_action(action, state).map(|a| vec![a])
}
#[cfg(test)]
mod tests {
use super::*;
use crate::audio::clip::AudioClip;
use crate::kind::Kind;
#[cfg(all(unix, not(target_os = "macos")))]
use crate::message::Lv2PluginState;
use crate::message::{MidiLearnBinding, TrackMidiLearnTarget};
use crate::mutex::UnsafeMutex;
use crate::track::Track;
use crate::vst3::Vst3PluginState;
use std::sync::Arc;
fn make_state_with_track(track: Track) -> State {
let mut state = State::default();
state.tracks.insert(
track.name.clone(),
Arc::new(UnsafeMutex::new(Box::new(track))),
);
state
}
fn binding(cc: u8) -> MidiLearnBinding {
MidiLearnBinding {
device: Some("midi".to_string()),
channel: 1,
cc,
}
}
#[test]
fn history_record_limits_size_and_clears_redo_on_new_entry() {
let mut history = History::new(2);
let a = UndoEntry {
forward_actions: vec![Action::SetTempo(120.0)],
inverse_actions: vec![Action::SetTempo(110.0)],
};
let b = UndoEntry {
forward_actions: vec![Action::SetLoopEnabled(true)],
inverse_actions: vec![Action::SetLoopEnabled(false)],
};
let c = UndoEntry {
forward_actions: vec![Action::SetMetronomeEnabled(true)],
inverse_actions: vec![Action::SetMetronomeEnabled(false)],
};
history.record(a);
history.record(b.clone());
history.record(c.clone());
let undo = history.undo().unwrap();
assert!(matches!(
undo.as_slice(),
[Action::SetMetronomeEnabled(false)]
));
let redo = history.redo().unwrap();
assert!(matches!(
redo.as_slice(),
[Action::SetMetronomeEnabled(true)]
));
history.undo();
history.record(UndoEntry {
forward_actions: vec![Action::SetClipPlaybackEnabled(true)],
inverse_actions: vec![Action::SetClipPlaybackEnabled(false)],
});
assert!(history.redo().is_none());
let undo = history.undo().unwrap();
assert!(matches!(
undo.as_slice(),
[Action::SetClipPlaybackEnabled(false)]
));
let undo = history.undo().unwrap();
assert!(matches!(undo.as_slice(), [Action::SetLoopEnabled(false)]));
assert!(history.undo().is_none());
}
#[test]
fn history_clear_removes_pending_undo_and_redo_entries() {
let mut history = History::new(4);
history.record(UndoEntry {
forward_actions: vec![Action::SetTempo(120.0)],
inverse_actions: vec![Action::SetTempo(100.0)],
});
history.record(UndoEntry {
forward_actions: vec![Action::SetLoopEnabled(true)],
inverse_actions: vec![Action::SetLoopEnabled(false)],
});
assert!(history.undo().is_some());
assert!(history.redo().is_some());
history.clear();
assert!(history.undo().is_none());
assert!(history.redo().is_none());
}
#[test]
fn history_with_zero_capacity_discards_recorded_entries() {
let mut history = History::new(0);
history.record(UndoEntry {
forward_actions: vec![Action::SetTempo(120.0)],
inverse_actions: vec![Action::SetTempo(100.0)],
});
assert!(history.undo().is_none());
assert!(history.redo().is_none());
}
#[test]
fn should_record_covers_recent_transport_and_lv2_actions() {
assert!(should_record(&Action::SetLoopEnabled(true)));
assert!(should_record(&Action::SetLoopRange(Some((64, 128)))));
assert!(should_record(&Action::SetPunchEnabled(true)));
assert!(should_record(&Action::SetPunchRange(Some((32, 96)))));
assert!(should_record(&Action::SetMetronomeEnabled(true)));
assert!(!should_record(&Action::SetClipPlaybackEnabled(false)));
assert!(!should_record(&Action::SetRecordEnabled(true)));
assert!(should_record(&Action::SetClipBounds {
track_name: "t".to_string(),
clip_index: 0,
kind: Kind::Audio,
start: 64,
length: 32,
offset: 16,
}));
assert!(should_record(&Action::TrackLoadVst3Plugin {
track_name: "t".to_string(),
plugin_path: "/tmp/test.vst3".to_string(),
}));
#[cfg(all(unix, not(target_os = "macos")))]
{
assert!(should_record(&Action::TrackLoadLv2Plugin {
track_name: "t".to_string(),
plugin_uri: "urn:test".to_string(),
}));
assert!(should_record(&Action::TrackSetLv2ControlValue {
track_name: "t".to_string(),
instance_id: 0,
index: 1,
value: 0.5,
}));
assert!(!should_record(&Action::TrackSetLv2PluginState {
track_name: "t".to_string(),
instance_id: 0,
state: Lv2PluginState {
port_values: vec![],
properties: vec![],
},
}));
}
assert!(!should_record(&Action::TrackVst3RestoreState {
track_name: "t".to_string(),
instance_id: 0,
state: Vst3PluginState {
plugin_id: "id".to_string(),
component_state: vec![],
controller_state: vec![],
},
}));
}
#[test]
fn create_inverse_action_for_add_clip_targets_next_clip_index() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
track
.audio
.clips
.push(AudioClip::new("existing".to_string(), 0, 16));
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::AddClip {
name: "new".to_string(),
track_name: "t".to_string(),
start: 32,
length: 16,
offset: 0,
input_channel: 0,
muted: false,
peaks_file: None,
kind: Kind::Audio,
fade_enabled: false,
fade_in_samples: 0,
fade_out_samples: 0,
source_name: None,
source_offset: None,
source_length: None,
preview_name: None,
pitch_correction_points: vec![],
pitch_correction_frame_likeness: None,
pitch_correction_inertia_ms: None,
pitch_correction_formant_compensation: None,
plugin_graph_json: None,
},
&state,
)
.unwrap();
match inverse {
Action::RemoveClip {
track_name,
kind,
clip_indices,
} => {
assert_eq!(track_name, "t");
assert_eq!(kind, Kind::Audio);
assert_eq!(clip_indices, vec![1]);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_set_clip_bounds_restores_previous_audio_bounds() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
let mut clip = AudioClip::new("clip".to_string(), 10, 30);
clip.offset = 7;
track.audio.clips.push(clip);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::SetClipBounds {
track_name: "t".to_string(),
clip_index: 0,
kind: Kind::Audio,
start: 14,
length: 22,
offset: 11,
},
&state,
)
.expect("inverse action");
match inverse {
Action::SetClipBounds {
track_name,
clip_index,
kind,
start,
length,
offset,
} => {
assert_eq!(track_name, "t");
assert_eq!(clip_index, 0);
assert_eq!(kind, Kind::Audio);
assert_eq!(start, 10);
assert_eq!(length, 30);
assert_eq!(offset, 7);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_set_clip_bounds_restores_previous_midi_bounds() {
let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
track.midi.clips.push(crate::midi::clip::MIDIClip {
name: "pattern.mid".to_string(),
start: 24,
end: 120,
offset: 9,
..Default::default()
});
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::SetClipBounds {
track_name: "t".to_string(),
clip_index: 0,
kind: Kind::MIDI,
start: 32,
length: 48,
offset: 4,
},
&state,
)
.expect("inverse action");
match inverse {
Action::SetClipBounds {
track_name,
clip_index,
kind,
start,
length,
offset,
} => {
assert_eq!(track_name, "t");
assert_eq!(clip_index, 0);
assert_eq!(kind, Kind::MIDI);
assert_eq!(start, 24);
assert_eq!(length, 120);
assert_eq!(offset, 9);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_set_clip_muted_restores_audio_and_midi_flags() {
let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
let mut audio_clip = AudioClip::new("audio.wav".to_string(), 0, 16);
audio_clip.muted = true;
track.audio.clips.push(audio_clip);
let midi_clip = crate::midi::clip::MIDIClip {
name: "pattern.mid".to_string(),
muted: false,
..Default::default()
};
track.midi.clips.push(midi_clip);
let state = make_state_with_track(track);
let audio_inverse = create_inverse_action(
&Action::SetClipMuted {
track_name: "t".to_string(),
clip_index: 0,
kind: Kind::Audio,
muted: false,
},
&state,
)
.expect("audio inverse");
let midi_inverse = create_inverse_action(
&Action::SetClipMuted {
track_name: "t".to_string(),
clip_index: 0,
kind: Kind::MIDI,
muted: true,
},
&state,
)
.expect("midi inverse");
assert!(matches!(
audio_inverse,
Action::SetClipMuted {
muted: true,
kind: Kind::Audio,
..
}
));
assert!(matches!(
midi_inverse,
Action::SetClipMuted {
muted: false,
kind: Kind::MIDI,
..
}
));
}
#[test]
fn create_inverse_action_for_rename_clip_restores_previous_name() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
track
.audio
.clips
.push(AudioClip::new("before.wav".to_string(), 0, 16));
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::RenameClip {
track_name: "t".to_string(),
kind: Kind::Audio,
clip_index: 0,
new_name: "after.wav".to_string(),
},
&state,
)
.expect("inverse action");
assert!(matches!(
inverse,
Action::RenameClip { new_name, kind: Kind::Audio, .. } if new_name == "before.wav"
));
}
#[test]
fn create_inverse_action_for_track_set_vca_master_restores_none() {
let track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::TrackSetVcaMaster {
track_name: "t".to_string(),
master_track: Some("bus".to_string()),
},
&state,
)
.expect("inverse action");
assert!(matches!(
inverse,
Action::TrackSetVcaMaster { track_name, master_track: None } if track_name == "t"
));
}
#[test]
fn create_inverse_action_for_remove_audio_clip_restores_peaks_file() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
let mut clip = AudioClip::new("audio/clip.wav".to_string(), 48, 144);
clip.offset = 12;
clip.input_channel = 0;
clip.muted = true;
clip.peaks_file = Some("peaks/clip.json".to_string());
track.audio.clips.push(clip);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::RemoveClip {
track_name: "t".to_string(),
kind: Kind::Audio,
clip_indices: vec![0],
},
&state,
)
.expect("inverse action");
match inverse {
Action::AddClip {
name,
track_name,
start,
length,
offset,
input_channel,
muted,
peaks_file,
kind,
..
} => {
assert_eq!(name, "audio/clip.wav");
assert_eq!(track_name, "t");
assert_eq!(start, 48);
assert_eq!(length, 96);
assert_eq!(offset, 12);
assert_eq!(input_channel, 0);
assert!(muted);
assert_eq!(peaks_file.as_deref(), Some("peaks/clip.json"));
assert_eq!(kind, Kind::Audio);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_remove_grouped_audio_clip_restores_group() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
let mut group = AudioClip::new("Group".to_string(), 48, 144);
group
.grouped_clips
.push(AudioClip::new("child.wav".to_string(), 0, 32));
track.audio.clips.push(group);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::RemoveClip {
track_name: "t".to_string(),
kind: Kind::Audio,
clip_indices: vec![0],
},
&state,
)
.expect("inverse action");
match inverse {
Action::AddGroupedClip {
track_name,
kind,
audio_clip,
midi_clip,
} => {
assert_eq!(track_name, "t");
assert_eq!(kind, Kind::Audio);
assert!(midi_clip.is_none());
let audio_clip = audio_clip.expect("audio clip payload");
assert_eq!(audio_clip.name, "Group");
assert_eq!(audio_clip.grouped_clips.len(), 1);
assert_eq!(audio_clip.grouped_clips[0].name, "child.wav");
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_remove_midi_clip_restores_clip() {
let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
track.midi.clips.push(crate::midi::clip::MIDIClip {
name: "pattern.mid".to_string(),
start: 48,
end: 144,
offset: 12,
input_channel: 3,
muted: true,
..Default::default()
});
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::RemoveClip {
track_name: "t".to_string(),
kind: Kind::MIDI,
clip_indices: vec![0],
},
&state,
)
.expect("inverse action");
match inverse {
Action::AddClip {
name,
track_name,
start,
length,
offset,
input_channel,
muted,
kind,
..
} => {
assert_eq!(name, "pattern.mid");
assert_eq!(track_name, "t");
assert_eq!(start, 48);
assert_eq!(length, 96);
assert_eq!(offset, 12);
assert_eq!(input_channel, 3);
assert!(muted);
assert_eq!(kind, Kind::MIDI);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_remove_grouped_midi_clip_restores_group() {
let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
group.grouped_clips.push(crate::midi::clip::MIDIClip::new(
"child.mid".to_string(),
0,
48,
));
track.midi.clips.push(group);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::RemoveClip {
track_name: "t".to_string(),
kind: Kind::MIDI,
clip_indices: vec![0],
},
&state,
)
.expect("inverse action");
match inverse {
Action::AddGroupedClip {
track_name,
kind,
audio_clip,
midi_clip,
} => {
assert_eq!(track_name, "t");
assert_eq!(kind, Kind::MIDI);
assert!(audio_clip.is_none());
let midi_clip = midi_clip.expect("midi clip payload");
assert_eq!(midi_clip.name, "Group");
assert_eq!(midi_clip.grouped_clips.len(), 1);
assert_eq!(midi_clip.grouped_clips[0].name, "child.mid");
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_remove_grouped_audio_clip_preserves_child_metadata() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
let mut child = AudioClip::new("child.wav".to_string(), 4, 40);
child.peaks_file = Some("peaks/child.json".to_string());
child.pitch_correction_source_name = Some("source.wav".to_string());
child.pitch_correction_source_offset = Some(8);
child.pitch_correction_source_length = Some(24);
child.pitch_correction_preview_name = Some("preview.wav".to_string());
child.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
start_sample: 1,
length_samples: 2,
detected_midi_pitch: 60.0,
target_midi_pitch: 62.0,
clarity: 0.75,
}];
child.pitch_correction_frame_likeness = Some(0.25);
child.pitch_correction_inertia_ms = Some(100);
child.pitch_correction_formant_compensation = Some(true);
child.plugin_graph_json = Some(serde_json::json!({"plugins":[],"connections":[]}));
let mut group = AudioClip::new("Group".to_string(), 48, 144);
group.grouped_clips.push(child);
track.audio.clips.push(group);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::RemoveClip {
track_name: "t".to_string(),
kind: Kind::Audio,
clip_indices: vec![0],
},
&state,
)
.expect("inverse action");
match inverse {
Action::AddGroupedClip {
audio_clip: Some(audio_clip),
..
} => {
let child = &audio_clip.grouped_clips[0];
assert_eq!(child.peaks_file.as_deref(), Some("peaks/child.json"));
assert_eq!(child.source_name.as_deref(), Some("source.wav"));
assert_eq!(child.source_offset, Some(8));
assert_eq!(child.source_length, Some(24));
assert_eq!(child.preview_name.as_deref(), Some("preview.wav"));
assert_eq!(child.pitch_correction_points.len(), 1);
assert_eq!(child.pitch_correction_frame_likeness, Some(0.25));
assert_eq!(child.pitch_correction_inertia_ms, Some(100));
assert_eq!(child.pitch_correction_formant_compensation, Some(true));
assert!(child.plugin_graph_json.is_some());
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_remove_grouped_midi_clip_preserves_child_structure() {
let mut track = Track::new("t".to_string(), 0, 0, 1, 1, 64, 48_000.0);
let child = crate::midi::clip::MIDIClip::new("child.mid".to_string(), 0, 48);
let mut group = crate::midi::clip::MIDIClip::new("Group".to_string(), 32, 160);
group.grouped_clips.push(child);
track.midi.clips.push(group);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::RemoveClip {
track_name: "t".to_string(),
kind: Kind::MIDI,
clip_indices: vec![0],
},
&state,
)
.expect("inverse action");
match inverse {
Action::AddGroupedClip {
midi_clip: Some(midi_clip),
..
} => {
let child = &midi_clip.grouped_clips[0];
assert_eq!(child.name, "child.mid");
assert_eq!(child.start, 0);
assert_eq!(child.length, 48);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_set_clip_pitch_correction_restores_previous_values() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
let mut clip = AudioClip::new("audio.wav".to_string(), 0, 128);
clip.pitch_correction_preview_name = Some("audio_preview.wav".to_string());
clip.pitch_correction_source_name = Some("audio_source.wav".to_string());
clip.pitch_correction_source_offset = Some(12);
clip.pitch_correction_source_length = Some(96);
clip.pitch_correction_points = vec![crate::message::PitchCorrectionPointData {
start_sample: 4,
length_samples: 32,
detected_midi_pitch: 60.2,
target_midi_pitch: 61.0,
clarity: 0.8,
}];
clip.pitch_correction_frame_likeness = Some(0.4);
clip.pitch_correction_inertia_ms = Some(250);
clip.pitch_correction_formant_compensation = Some(false);
track.audio.clips.push(clip);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::SetClipPitchCorrection {
track_name: "t".to_string(),
clip_index: 0,
preview_name: None,
source_name: None,
source_offset: None,
source_length: None,
pitch_correction_points: vec![],
pitch_correction_frame_likeness: None,
pitch_correction_inertia_ms: None,
pitch_correction_formant_compensation: None,
},
&state,
)
.expect("inverse action");
match inverse {
Action::SetClipPitchCorrection {
track_name,
clip_index,
preview_name,
source_name,
source_offset,
source_length,
pitch_correction_points,
pitch_correction_frame_likeness,
pitch_correction_inertia_ms,
pitch_correction_formant_compensation,
} => {
assert_eq!(track_name, "t");
assert_eq!(clip_index, 0);
assert_eq!(preview_name.as_deref(), Some("audio_preview.wav"));
assert_eq!(source_name.as_deref(), Some("audio_source.wav"));
assert_eq!(source_offset, Some(12));
assert_eq!(source_length, Some(96));
assert_eq!(pitch_correction_points.len(), 1);
assert_eq!(pitch_correction_points[0].target_midi_pitch, 61.0);
assert_eq!(pitch_correction_frame_likeness, Some(0.4));
assert_eq!(pitch_correction_inertia_ms, Some(250));
assert_eq!(pitch_correction_formant_compensation, Some(false));
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_clip_copy_targets_new_destination_clip() {
let mut source = Track::new("src".to_string(), 1, 1, 0, 0, 64, 48_000.0);
source
.audio
.clips
.push(AudioClip::new("source.wav".to_string(), 12, 48));
let mut dest = Track::new("dst".to_string(), 1, 1, 0, 0, 64, 48_000.0);
dest.audio
.clips
.push(AudioClip::new("existing.wav".to_string(), 0, 24));
let mut state = State::default();
state.tracks.insert(
source.name.clone(),
Arc::new(UnsafeMutex::new(Box::new(source))),
);
state.tracks.insert(
dest.name.clone(),
Arc::new(UnsafeMutex::new(Box::new(dest))),
);
let inverse = create_inverse_action(
&Action::ClipMove {
kind: Kind::Audio,
from: ClipMoveFrom {
track_name: "src".to_string(),
clip_index: 0,
},
to: ClipMoveTo {
track_name: "dst".to_string(),
sample_offset: 96,
input_channel: 0,
},
copy: true,
},
&state,
)
.expect("inverse action");
match inverse {
Action::RemoveClip {
track_name,
kind,
clip_indices,
} => {
assert_eq!(track_name, "dst");
assert_eq!(kind, Kind::Audio);
assert_eq!(clip_indices, vec![1]);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_same_track_clip_move_reverses_last_destination_clip() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
let mut original = AudioClip::new("clip.wav".to_string(), 20, 40);
original.input_channel = 2;
let moved = AudioClip::new("moved.wav".to_string(), 80, 32);
track.audio.clips.push(original);
track.audio.clips.push(moved);
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::ClipMove {
kind: Kind::Audio,
from: ClipMoveFrom {
track_name: "t".to_string(),
clip_index: 0,
},
to: ClipMoveTo {
track_name: "t".to_string(),
sample_offset: 80,
input_channel: 1,
},
copy: false,
},
&state,
)
.expect("inverse action");
match inverse {
Action::ClipMove {
kind,
from,
to,
copy,
} => {
assert_eq!(kind, Kind::Audio);
assert_eq!(from.track_name, "t");
assert_eq!(from.clip_index, 1);
assert_eq!(to.track_name, "t");
assert_eq!(to.sample_offset, 20);
assert_eq!(to.input_channel, 2);
assert!(!copy);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_track_midi_binding_restores_previous_binding() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
track.midi_learn_volume = Some(binding(7));
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::TrackSetMidiLearnBinding {
track_name: "t".to_string(),
target: TrackMidiLearnTarget::Volume,
binding: Some(binding(9)),
},
&state,
)
.unwrap();
match inverse {
Action::TrackSetMidiLearnBinding {
track_name,
target,
binding,
} => {
assert_eq!(track_name, "t");
assert_eq!(target, TrackMidiLearnTarget::Volume);
assert_eq!(binding.unwrap().cc, 7);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_action_for_vst3_load_uses_next_runtime_instance_id() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
track.next_plugin_instance_id = 42;
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::TrackLoadVst3Plugin {
track_name: "t".to_string(),
plugin_path: "/tmp/test.vst3".to_string(),
},
&state,
)
.unwrap();
match inverse {
Action::TrackUnloadVst3PluginInstance {
track_name,
instance_id,
} => {
assert_eq!(track_name, "t");
assert_eq!(instance_id, 42);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
#[cfg(all(unix, not(target_os = "macos")))]
fn create_inverse_action_for_lv2_load_uses_next_runtime_instance_id() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
track.next_lv2_instance_id = 5;
let state = make_state_with_track(track);
let inverse = create_inverse_action(
&Action::TrackLoadLv2Plugin {
track_name: "t".to_string(),
plugin_uri: "urn:test".to_string(),
},
&state,
)
.unwrap();
match inverse {
Action::TrackUnloadLv2PluginInstance {
track_name,
instance_id,
} => {
assert_eq!(track_name, "t");
assert_eq!(instance_id, 5);
}
other => panic!("unexpected inverse action: {other:?}"),
}
}
#[test]
fn create_inverse_actions_for_clear_all_midi_learn_bindings_restores_only_existing_bindings() {
let mut track = Track::new("t".to_string(), 1, 1, 0, 0, 64, 48_000.0);
track.midi_learn_volume = Some(binding(7));
track.midi_learn_disk_monitor = Some(binding(64));
let state = make_state_with_track(track);
let inverses = create_inverse_actions(&Action::ClearAllMidiLearnBindings, &state).unwrap();
assert_eq!(inverses.len(), 2);
assert!(inverses.iter().any(|action| {
matches!(
action,
Action::TrackSetMidiLearnBinding {
target: TrackMidiLearnTarget::Volume,
binding: Some(MidiLearnBinding { cc: 7, .. }),
..
}
)
}));
assert!(inverses.iter().any(|action| {
matches!(
action,
Action::TrackSetMidiLearnBinding {
target: TrackMidiLearnTarget::DiskMonitor,
binding: Some(MidiLearnBinding { cc: 64, .. }),
..
}
)
}));
}
#[test]
fn create_inverse_actions_for_remove_track_restores_io_flags_and_bindings() {
let mut track = Track::new("t".to_string(), 1, 1, 1, 1, 64, 48_000.0);
track.level = -3.0;
track.balance = 0.25;
track.armed = true;
track.muted = true;
track.soloed = true;
track.input_monitor = true;
track.disk_monitor = false;
track.midi_learn_volume = Some(binding(10));
track.vca_master = Some("bus".to_string());
track.audio.ins.push(Arc::new(AudioIO::new(64)));
track.audio.outs.push(Arc::new(AudioIO::new(64)));
let state = make_state_with_track(track);
let inverses =
create_inverse_actions(&Action::RemoveTrack("t".to_string()), &state).unwrap();
assert!(matches!(
inverses.first(),
Some(Action::AddTrack {
name,
audio_ins: 1,
audio_outs: 1,
midi_ins: 1,
midi_outs: 1,
}) if name == "t"
));
assert!(
inverses
.iter()
.any(|action| matches!(action, Action::TrackAddAudioInput(name) if name == "t"))
);
assert!(
inverses
.iter()
.any(|action| matches!(action, Action::TrackAddAudioOutput(name) if name == "t"))
);
assert!(
inverses.iter().any(
|action| matches!(action, Action::TrackToggleInputMonitor(name) if name == "t")
)
);
assert!(
inverses.iter().any(
|action| matches!(action, Action::TrackToggleDiskMonitor(name) if name == "t")
)
);
assert!(inverses.iter().any(|action| {
matches!(
action,
Action::TrackSetMidiLearnBinding {
target: TrackMidiLearnTarget::Volume,
binding: Some(MidiLearnBinding { cc: 10, .. }),
..
}
)
}));
assert!(inverses.iter().any(|action| {
matches!(
action,
Action::TrackSetVcaMaster {
track_name,
master_track: Some(master),
} if track_name == "t" && master == "bus"
)
}));
}
}