use super::{
AUDIO_PEAK_UPDATES, AutomationWriteKey, CLIENT, MIN_CLIP_WIDTH_PX, Maolan,
TouchAutomationOverride, platform,
};
mod autosave;
mod dispatch;
mod hw;
mod types;
use self::types::{
AutomationTrackView, MidiMappingsFile, MidiMappingsGlobalFile, MidiMappingsTrackFile,
};
#[cfg(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd"))]
use crate::state::AudioDeviceOption;
use crate::{
connections,
consts::gui::{AUTOSAVE_SNAPSHOT_INTERVAL, METER_QUANTIZE_STEP_DB},
consts::gui_update_mod::ATTACK_ALPHA,
consts::state_ids::METRONOME_TRACK_ID,
consts::state_track::TRACK_SUBTRACK_MIN_HEIGHT,
consts::widget_piano::PITCH_MAX,
message::{
ClipPitchCorrectionRequest, ClipStretchRequest, ExportNormalizeMode, ExportRenderMode,
Message, Show, TrackAutomationMode, TrackAutomationTarget,
},
platform_caps,
state::{
ConnectionViewSelection, HW, PianoData, PianoSysExPoint, Resizing, TempoPoint,
TimeSignaturePoint, Track, TrackAutomationLane, TrackAutomationPoint, View,
},
ui_timing::DOUBLE_CLICK,
widget::midi_edit::{CTRL_SCROLL_ID, KEYS_SCROLL_ID, NOTES_SCROLL_ID, SYSEX_SCROLL_ID},
workspace::{
EDITOR_SCROLL_ID, EDITOR_TIMELINE_SCROLL_ID, PIANO_RULER_SCROLL_ID, PIANO_TEMPO_SCROLL_ID,
TRACKS_SCROLL_ID, WORKSPACE_RULER_SCROLL_ID, WORKSPACE_TEMPO_SCROLL_ID,
},
};
use iced::widget::{Id, operation};
use iced::{Length, Point, Task, mouse};
use maolan_engine::message::PluginGraphPlugin;
use maolan_engine::{
history,
kind::Kind,
message::{
Action, ClipMoveFrom, ClipMoveTo, Message as EngineMessage, OfflineAutomationLane,
OfflineAutomationPoint, OfflineAutomationTarget, SessionAction,
SessionSlotState as EngineSessionSlotState,
},
};
use rfd::AsyncFileDialog;
use serde::{Deserialize, Serialize};
use std::{
collections::{HashMap, HashSet, hash_map::DefaultHasher},
fs,
hash::{Hash, Hasher},
io,
process::exit,
time::{Duration, Instant, SystemTime, UNIX_EPOCH},
};
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedPitchCorrectionPoint {
start_sample: usize,
length_samples: usize,
detected_midi_pitch: f32,
target_midi_pitch: f32,
clarity: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CachedPitchCorrectionFile {
source_name: String,
source_offset: usize,
source_length: usize,
source_modified_unix_nanos: Option<u128>,
raw_points: Vec<CachedPitchCorrectionPoint>,
clip_length_samples: usize,
frame_likeness: f32,
}
impl Maolan {
fn pitch_correction_cache_file_name(
source_name: &str,
source_offset: usize,
source_length: usize,
) -> String {
let mut hasher = DefaultHasher::new();
source_name.hash(&mut hasher);
source_offset.hash(&mut hasher);
source_length.hash(&mut hasher);
format!("{:016x}.json", hasher.finish())
}
fn pitch_correction_cache_file_path(
session_root: &std::path::Path,
source_name: &str,
source_offset: usize,
source_length: usize,
) -> std::path::PathBuf {
session_root
.join("pitch")
.join(Self::pitch_correction_cache_file_name(
source_name,
source_offset,
source_length,
))
}
fn source_modified_unix_nanos(path: &std::path::Path) -> io::Result<u128> {
let modified = fs::metadata(path)?.modified()?;
let duration = modified.duration_since(UNIX_EPOCH).map_err(|e| {
io::Error::other(format!(
"Failed to read modification time for '{}': {e}",
path.display()
))
})?;
Ok(duration.as_nanos())
}
fn load_cached_pitch_correction(
session_root: &std::path::Path,
source_path: &std::path::Path,
request: &ClipPitchCorrectionRequest,
) -> io::Result<Option<crate::state::PitchCorrectionData>> {
let path = Self::pitch_correction_cache_file_path(
session_root,
&request.source_name,
request.source_offset,
request.source_length,
);
let cache_file = match fs::File::open(&path) {
Ok(file) => file,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err),
};
let cache: CachedPitchCorrectionFile =
serde_json::from_reader(cache_file).map_err(|e| {
io::Error::other(format!(
"Failed to read pitch correction cache '{}': {e}",
path.display()
))
})?;
if cache.source_name != request.source_name
|| cache.source_offset != request.source_offset
|| cache.source_length != request.source_length
{
return Ok(None);
}
if let Some(expected_modified_nanos) = cache.source_modified_unix_nanos {
match Self::source_modified_unix_nanos(source_path) {
Ok(current_modified_nanos) if current_modified_nanos == expected_modified_nanos => {
}
_ => return Ok(None),
}
}
if cache.raw_points.is_empty() {
return Ok(None);
}
let frame_likeness = request.frame_likeness.clamp(0.05, 2.0);
let raw_points: Vec<crate::state::PitchCorrectionPoint> = cache
.raw_points
.into_iter()
.map(|point| crate::state::PitchCorrectionPoint {
start_sample: point.start_sample,
length_samples: point.length_samples,
detected_midi_pitch: point.detected_midi_pitch,
target_midi_pitch: point.target_midi_pitch,
clarity: point.clarity,
})
.collect();
let max_gap_samples = raw_points
.iter()
.map(|point| point.length_samples)
.min()
.unwrap_or(1)
.max(1);
let points = Self::merge_adjacent_pitch_fragments(
raw_points.clone(),
frame_likeness,
max_gap_samples,
);
Ok(Some(crate::state::PitchCorrectionData {
track_idx: request.track_idx.clone(),
clip_index: request.clip_idx,
clip_name: request.clip_name.clone(),
clip_length_samples: request.source_length.max(1),
frame_likeness,
raw_points,
points,
}))
}
fn save_cached_pitch_correction(
session_root: &std::path::Path,
source_path: &std::path::Path,
request: &ClipPitchCorrectionRequest,
pitch_correction: &crate::state::PitchCorrectionData,
) -> io::Result<()> {
fs::create_dir_all(session_root.join("pitch"))?;
let path = Self::pitch_correction_cache_file_path(
session_root,
&request.source_name,
request.source_offset,
request.source_length,
);
let source_modified_unix_nanos = Self::source_modified_unix_nanos(source_path).ok();
let raw_points = pitch_correction
.raw_points
.iter()
.map(|point| CachedPitchCorrectionPoint {
start_sample: point.start_sample,
length_samples: point.length_samples,
detected_midi_pitch: point.detected_midi_pitch,
target_midi_pitch: point.target_midi_pitch,
clarity: point.clarity,
})
.collect();
let cache = CachedPitchCorrectionFile {
source_name: request.source_name.clone(),
source_offset: request.source_offset,
source_length: request.source_length,
source_modified_unix_nanos,
raw_points,
clip_length_samples: pitch_correction.clip_length_samples,
frame_likeness: pitch_correction.frame_likeness,
};
let file = fs::File::create(&path)?;
serde_json::to_writer_pretty(file, &cache).map_err(|e| {
io::Error::other(format!(
"Failed to write pitch correction cache '{}': {e}",
path.display()
))
})
}
fn quantize_meter_db(level_db: f32) -> f32 {
let step = METER_QUANTIZE_STEP_DB;
((level_db / step).round() * step).clamp(-90.0, 20.0)
}
fn reset_track_plugin_view_state(state: &mut crate::state::StateData) {
state.plugin_graph_connecting = None;
state.plugin_graph_moving_plugin = None;
state.plugin_graph_last_plugin_click = None;
state.plugin_graph_selected_plugins.clear();
state.plugin_graph_selected_connections.clear();
state.plugin_graph_selected_connectable_connections.clear();
}
fn load_track_connection_view(
&self,
state: &mut crate::state::StateData,
track_name: &str,
) -> Task<Message> {
state.plugin_graph_track = Some(track_name.to_string());
state.plugin_graph_clip = None;
Self::reset_track_plugin_view_state(state);
if let Some((plugins, connections)) = state.plugin_graphs_by_track.get(track_name).cloned()
{
state.plugin_graph_plugins = plugins.clone();
state.plugin_graph_connections = connections;
let track_positions = state
.plugin_graph_plugin_positions
.entry(track_name.to_string())
.or_default();
for (idx, plugin) in plugins.iter().enumerate() {
let fallback = Point::new(200.0 + idx as f32 * 180.0, 220.0);
track_positions
.entry(plugin.instance_id)
.or_insert(fallback);
}
}
let node_size = 140.0_f32;
let spacing = 40.0_f32;
let start_x = 80.0_f32;
let folder_y = 80.0_f32;
let children_y = folder_y + node_size + spacing;
let canvas_w = self.size.width.max(600.0);
let default_position = Point::new(100.0, 100.0);
if let Some(track_idx) = state.tracks.iter().position(|t| t.name == track_name)
&& state.tracks[track_idx].position == default_position
{
state.tracks[track_idx].position = Point::new(start_x, folder_y);
let child_names: Vec<String> = state
.tracks
.iter()
.filter(|t| t.parent_track.as_deref() == Some(track_name))
.map(|t| t.name.clone())
.collect();
if !child_names.is_empty() {
let cols = ((canvas_w - start_x * 2.0) / (node_size + spacing))
.floor()
.max(1.0) as usize;
let cols = cols.min(child_names.len());
for (i, name) in child_names.iter().enumerate() {
let col = i % cols;
let row = i / cols;
let x = start_x + col as f32 * (node_size + spacing);
let y = children_y + row as f32 * (node_size + spacing);
if let Some(child) = state.tracks.iter_mut().find(|t| t.name == *name)
&& child.position == default_position
{
child.position = Point::new(x, y);
}
}
}
}
self.send(Action::TrackGetPluginGraph {
track_name: track_name.to_string(),
include_state: false,
})
}
fn open_track_plugins_followup(&self, _track_name: String) -> Task<Message> {
self.send(Action::TrackGetPluginGraph {
track_name: _track_name,
include_state: false,
})
}
fn maybe_refresh_plugin_graph_for_track(&self, track_name: &str) -> Option<Task<Message>> {
if self.track_has_open_plugin_graph(track_name) {
Some(self.send(Action::TrackGetPluginGraph {
track_name: track_name.to_string(),
include_state: false,
}))
} else {
None
}
}
fn track_has_open_plugin_graph(&self, track_name: &str) -> bool {
platform_caps::SUPPORTS_PLUGIN_GRAPH && {
let state = self.state.blocking_read();
state.plugin_graph_clip.is_none()
&& state.plugin_graph_track.as_deref() == Some(track_name)
}
}
fn save_open_clip_plugin_graph(
state: &mut crate::state::StateData,
) -> Option<maolan_engine::message::Action> {
let target = state.plugin_graph_clip.clone()?;
if let Some(track) = state
.tracks
.iter_mut()
.find(|track| track.name == target.track_name)
&& let Some(clip) = track.audio.clips.get_mut(target.clip_idx)
{
let graph_json = Self::plugin_graph_snapshot_to_json(
clip.plugin_graph_json.as_ref(),
&state.plugin_graph_plugins,
&state.plugin_graph_connections,
);
clip.plugin_graph_json = Some(graph_json);
return Some(Action::SetClipPluginGraphJson {
track_name: target.track_name,
clip_index: target.clip_idx,
plugin_graph_json: clip.plugin_graph_json.clone(),
});
}
None
}
pub(super) fn default_clip_plugin_graph_json(
audio_ins: usize,
audio_outs: usize,
) -> serde_json::Value {
let connections = (0..audio_ins.min(audio_outs))
.map(|port| {
serde_json::json!({
"from_node": "TrackInput",
"from_port": port,
"to_node": "TrackOutput",
"to_port": port,
"kind": "Audio",
})
})
.collect::<Vec<_>>();
serde_json::json!({
"plugins": [],
"connections": connections,
})
}
#[allow(dead_code)]
fn clip_plugin_graph_json_or_default(
graph_json: Option<serde_json::Value>,
audio_ins: usize,
audio_outs: usize,
) -> serde_json::Value {
graph_json.unwrap_or_else(|| Self::default_clip_plugin_graph_json(audio_ins, audio_outs))
}
pub(crate) fn audio_clip_to_data(
clip: &crate::state::AudioClip,
) -> maolan_engine::message::AudioClipData {
let mut data = maolan_engine::message::AudioClipData {
id: clip.id.clone(),
name: clip.name.clone(),
start: clip.start,
length: clip.length,
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
.iter()
.map(|point| maolan_engine::message::PitchCorrectionPointData {
start_sample: point.start_sample,
length_samples: point.length_samples,
detected_midi_pitch: point.detected_midi_pitch,
target_midi_pitch: point.target_midi_pitch,
clarity: point.clarity,
})
.collect(),
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(Self::audio_clip_to_data)
.collect(),
};
for child in &mut data.grouped_clips {
child.fade_enabled = false;
child.fade_in_samples = 0;
child.fade_out_samples = 0;
}
data
}
pub(crate) fn audio_clip_from_data(
data: &maolan_engine::message::AudioClipData,
max_length_samples: usize,
source_length_samples: usize,
) -> crate::state::AudioClip {
let mut clip = crate::state::AudioClip {
id: data.id.clone(),
name: data.name.clone(),
start: data.start,
length: data.length,
offset: data.offset,
input_channel: data.input_channel,
muted: data.muted,
max_length_samples,
source_length_samples,
peaks_file: data.peaks_file.clone(),
peaks: Default::default(),
fade_enabled: data.fade_enabled,
fade_in_samples: data.fade_in_samples,
fade_out_samples: data.fade_out_samples,
pitch_correction_preview_name: data.preview_name.clone(),
pitch_correction_source_name: data.source_name.clone(),
pitch_correction_source_offset: data.source_offset,
pitch_correction_source_length: data.source_length,
pitch_correction_points: data
.pitch_correction_points
.iter()
.map(|point| crate::state::PitchCorrectionPoint {
start_sample: point.start_sample,
length_samples: point.length_samples,
detected_midi_pitch: point.detected_midi_pitch,
target_midi_pitch: point.target_midi_pitch,
clarity: point.clarity,
})
.collect(),
pitch_correction_frame_likeness: data.pitch_correction_frame_likeness,
pitch_correction_inertia_ms: data.pitch_correction_inertia_ms,
pitch_correction_formant_compensation: data.pitch_correction_formant_compensation,
take_lane_override: None,
take_lane_pinned: false,
take_lane_locked: false,
plugin_graph_json: data.plugin_graph_json.clone(),
grouped_clips: data
.grouped_clips
.iter()
.map(|child| {
Self::audio_clip_from_data(child, max_length_samples, source_length_samples)
})
.collect(),
};
clip.normalize_group_children();
clip
}
pub(crate) fn midi_clip_to_data(
clip: &crate::state::MIDIClip,
) -> maolan_engine::message::MidiClipData {
maolan_engine::message::MidiClipData {
id: clip.id.clone(),
name: clip.name.clone(),
start: clip.start,
length: clip.length,
offset: clip.offset,
input_channel: clip.input_channel,
muted: clip.muted,
grouped_clips: clip
.grouped_clips
.iter()
.map(Self::midi_clip_to_data)
.collect(),
}
}
pub(crate) fn midi_clip_from_data(
data: &maolan_engine::message::MidiClipData,
max_length_samples: usize,
) -> crate::state::MIDIClip {
let mut clip = crate::state::MIDIClip {
id: data.id.clone(),
name: data.name.clone(),
start: data.start,
length: data.length,
offset: data.offset,
input_channel: data.input_channel,
muted: data.muted,
max_length_samples,
take_lane_override: None,
take_lane_pinned: false,
take_lane_locked: false,
grouped_clips: data
.grouped_clips
.iter()
.map(|child| Self::midi_clip_from_data(child, max_length_samples))
.collect(),
};
clip.normalize_group_children();
clip
}
pub(crate) fn audio_clip_add_action(
clip_id: &str,
track_name: &str,
clip: &crate::state::AudioClip,
start: usize,
length: usize,
) -> maolan_engine::message::Action {
maolan_engine::message::Action::AddClip {
clip_id: clip_id.to_string(),
name: clip.name.clone(),
track_name: track_name.to_string(),
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
.iter()
.map(|point| maolan_engine::message::PitchCorrectionPointData {
start_sample: point.start_sample,
length_samples: point.length_samples,
detected_midi_pitch: point.detected_midi_pitch,
target_midi_pitch: point.target_midi_pitch,
clarity: point.clarity,
})
.collect(),
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(),
}
}
pub(crate) fn midi_clip_add_action(
clip_id: &str,
track_name: &str,
clip: &crate::state::MIDIClip,
start: usize,
length: usize,
) -> maolan_engine::message::Action {
maolan_engine::message::Action::AddClip {
clip_id: clip_id.to_string(),
name: clip.name.clone(),
track_name: track_name.to_string(),
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,
}
}
fn clip_name_with_incremented_suffix(name: &str) -> String {
let path = std::path::Path::new(name);
let parent = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty());
let stem = path
.file_stem()
.and_then(|stem| stem.to_str())
.unwrap_or(name);
let extension = path.extension().and_then(|extension| extension.to_str());
let digit_start = stem
.char_indices()
.rev()
.find_map(|(idx, ch)| (!ch.is_ascii_digit()).then_some(idx + ch.len_utf8()))
.unwrap_or(0);
let next_stem = if digit_start < stem.len() {
let prefix = &stem[..digit_start];
let number = stem[digit_start..].parse::<usize>().unwrap_or(0);
format!("{prefix}{}", number.saturating_add(1))
} else {
format!("{stem}_1")
};
let mut file_name = next_stem;
if let Some(extension) = extension {
file_name.push('.');
file_name.push_str(extension);
}
parent
.map(|parent| parent.join(&file_name).to_string_lossy().to_string())
.unwrap_or(file_name)
}
fn unique_clip_name_from_used(base_name: &str, used_names: &HashSet<String>) -> String {
let mut candidate = Self::clip_name_with_incremented_suffix(base_name);
while used_names.contains(&candidate) {
candidate = Self::clip_name_with_incremented_suffix(&candidate);
}
candidate
}
fn clip_file_name_for_rename(kind: Kind, old_name: &str, new_name: &str) -> String {
match kind {
Kind::Audio => format!("audio/{}.wav", new_name),
Kind::MIDI => {
let ext = std::path::Path::new(old_name)
.extension()
.and_then(|ext| ext.to_str())
.map(|s| s.to_ascii_lowercase())
.filter(|ext| ext == "mid" || ext == "midi")
.unwrap_or_else(|| "mid".to_string());
format!("midi/{}.{}", new_name, ext)
}
}
}
fn resolve_session_clip_path(&self, name: &str) -> Option<std::path::PathBuf> {
let path = std::path::Path::new(name);
if path.is_absolute() {
Some(path.to_path_buf())
} else {
self.session_dir.as_ref().map(|session| session.join(path))
}
}
fn copy_clip_backing_file_if_present(&self, old_name: &str, new_name: &str) -> io::Result<()> {
let Some(old_path) = self.resolve_session_clip_path(old_name) else {
return Ok(());
};
let Some(new_path) = self.resolve_session_clip_path(new_name) else {
return Ok(());
};
if old_path == new_path || !old_path.exists() || new_path.exists() {
return Ok(());
}
if let Some(parent) = new_path.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(old_path, new_path).map(|_| ())
}
fn clip_identity_context(
&self,
track_name: &str,
clip_index: usize,
kind: Kind,
) -> Option<(String, String, usize, HashSet<String>)> {
let state = self.state.blocking_read();
let mut id = None;
let mut name = None;
let mut matching_id_count = 0usize;
let mut used_names = HashSet::new();
for track in &state.tracks {
match kind {
Kind::Audio => {
for (idx, clip) in track.audio.clips.iter().enumerate() {
used_names.insert(clip.name.clone());
if track.name == track_name && idx == clip_index {
id = Some(clip.id.clone());
name = Some(clip.name.clone());
}
}
}
Kind::MIDI => {
for (idx, clip) in track.midi.clips.iter().enumerate() {
used_names.insert(clip.name.clone());
if track.name == track_name && idx == clip_index {
id = Some(clip.id.clone());
name = Some(clip.name.clone());
}
}
}
}
}
let id = id?;
let name = name?;
for track in &state.tracks {
matching_id_count += match kind {
Kind::Audio => track
.audio
.clips
.iter()
.filter(|clip| clip.id == id)
.count(),
Kind::MIDI => track.midi.clips.iter().filter(|clip| clip.id == id).count(),
};
}
matching_id_count += match kind {
Kind::Audio => state
.unused_audio_clips
.iter()
.filter(|clip| clip.id == id)
.count(),
Kind::MIDI => state
.unused_midi_clips
.iter()
.filter(|clip| clip.id == id)
.count(),
};
Some((id, name, matching_id_count, used_names))
}
pub(super) fn action_with_clip_identity_fork(&self, action: Action) -> Action {
self.action_with_clip_identity_fork_impl(action, false)
}
pub(super) fn action_with_confirmed_clip_length_change(&self, action: Action) -> Action {
self.action_with_clip_identity_fork_impl(action, true)
}
fn action_with_clip_identity_fork_impl(
&self,
action: Action,
confirmed_length_change: bool,
) -> Action {
if let Action::ApplyGroupedActions(actions) = action {
let mut forked_actions = Vec::new();
for action in actions {
match self.action_with_clip_identity_fork(action) {
Action::ApplyGroupedActions(actions) => forked_actions.extend(actions),
action => forked_actions.push(action),
}
}
return Action::ApplyGroupedActions(forked_actions);
}
let Some((track_name, clip_index, kind, length_change, rename_name)) = (match &action {
Action::SetClipBounds {
track_name,
clip_index,
kind,
length,
..
} => Some((track_name.clone(), *clip_index, *kind, Some(*length), None)),
Action::RenameClip {
track_name,
clip_index,
kind,
new_name,
} => Some((
track_name.clone(),
*clip_index,
*kind,
None,
Some(new_name.clone()),
)),
Action::ModifyMidiNotes {
track_name,
clip_index,
..
}
| Action::ModifyMidiControllers {
track_name,
clip_index,
..
}
| Action::DeleteMidiControllers {
track_name,
clip_index,
..
}
| Action::InsertMidiControllers {
track_name,
clip_index,
..
}
| Action::DeleteMidiNotes {
track_name,
clip_index,
..
}
| Action::InsertMidiNotes {
track_name,
clip_index,
..
} => Some((track_name.clone(), *clip_index, Kind::MIDI, None, None)),
_ => None,
}) else {
return action;
};
let Some((_old_id, old_name, matching_id_count, used_names)) =
self.clip_identity_context(&track_name, clip_index, kind)
else {
return action;
};
if matching_id_count <= 1 {
return action;
}
if let Some(new_length) = length_change
&& !confirmed_length_change
{
let old_length = {
let state = self.state.blocking_read();
state
.tracks
.iter()
.find(|track| track.name == track_name)
.and_then(|track| match kind {
Kind::Audio => track.audio.clips.get(clip_index).map(|clip| clip.length),
Kind::MIDI => track.midi.clips.get(clip_index).map(|clip| clip.length),
})
};
if old_length.is_some_and(|old_length| old_length == new_length.max(1)) {
return action;
}
}
let new_name = rename_name
.as_deref()
.map(|new_name| Self::clip_file_name_for_rename(kind, &old_name, new_name))
.unwrap_or_else(|| Self::unique_clip_name_from_used(&old_name, &used_names));
if let Err(err) = self.copy_clip_backing_file_if_present(&old_name, &new_name) {
self.state.blocking_write().message =
format!("Failed to fork clip backing file '{}': {err}", old_name);
return action;
}
let identity_action = Action::SetClipIdentity {
track_name,
kind,
clip_index,
new_id: crate::state::generate_clip_id(),
new_name,
};
if rename_name.is_some() {
identity_action
} else {
Action::ApplyGroupedActions(vec![identity_action, action])
}
}
#[allow(dead_code)]
fn open_clip_plugin_view(&mut self, track_name: String, clip_idx: usize) -> Task<Message> {
let (graph_json, track_audio_ins, track_audio_outs, vst3_plugins, clap_plugins) = {
let state = self.state.blocking_read();
let track = state.tracks.iter().find(|track| track.name == track_name);
let graph_json = track
.and_then(|track| track.audio.clips.get(clip_idx))
.and_then(|clip| clip.plugin_graph_json.clone());
let (track_audio_ins, track_audio_outs) = track
.map(|track| (track.audio.ins, track.audio.outs))
.unwrap_or((0, 0));
(
graph_json,
track_audio_ins,
track_audio_outs,
state.vst3_plugins.clone(),
state.clap_plugins.clone(),
)
};
#[cfg(all(unix, not(target_os = "macos")))]
let (plugins, connections) = {
let lv2_plugins = self.state.blocking_read().lv2_plugins.clone();
Self::plugin_graph_snapshot_from_json(
graph_json.as_ref(),
&lv2_plugins,
&vst3_plugins,
&clap_plugins,
)
};
#[cfg(not(all(unix, not(target_os = "macos"))))]
let (plugins, connections) = Self::plugin_graph_snapshot_from_json(
graph_json.as_ref(),
&vst3_plugins,
&clap_plugins,
);
let target_track_name = track_name.clone();
let mut state = self.state.blocking_write();
state.view = crate::state::View::TrackPlugins;
state.plugin_graph_track = Some(track_name.clone());
state.plugin_graph_clip = Some(crate::state::PluginGraphClipTarget {
track_name,
clip_idx,
});
Self::reset_track_plugin_view_state(&mut state);
state.plugin_graph_plugins = plugins.clone();
state.plugin_graph_connections = connections;
let track_positions = state
.plugin_graph_plugin_positions
.entry(target_track_name.clone())
.or_default();
for (idx, plugin) in plugins.iter().enumerate() {
let fallback = iced::Point::new(200.0 + idx as f32 * 180.0, 220.0);
track_positions
.entry(plugin.instance_id)
.or_insert(fallback);
}
if graph_json.is_none()
&& let Some(track) = state
.tracks
.iter_mut()
.find(|track| track.name == target_track_name)
&& let Some(clip) = track.audio.clips.get_mut(clip_idx)
{
let graph_json =
Self::clip_plugin_graph_json_or_default(None, track_audio_ins, track_audio_outs);
clip.plugin_graph_json = Some(graph_json.clone());
return self.send(Action::SetClipPluginGraphJson {
track_name: target_track_name,
clip_index: clip_idx,
plugin_graph_json: Some(graph_json),
});
}
Task::none()
}
fn queue_pending_graph_automation_queries(
&mut self,
track_name: &str,
plugins: &[PluginGraphPlugin],
) -> Vec<Task<Message>> {
let mut pending_queries: Vec<Task<Message>> = vec![];
#[cfg(all(unix, not(target_os = "macos")))]
self.queue_pending_lv2_automation_queries(track_name, plugins, &mut pending_queries);
let pending_vst3_paths: Vec<(String, String)> = self
.pending_add_vst3_automation_paths
.iter()
.filter(|(name, _)| name == track_name)
.cloned()
.collect();
for (pending_track, pending_path) in pending_vst3_paths {
if let Some(instance_id) = plugins
.iter()
.find(|plugin| {
plugin.format.eq_ignore_ascii_case("VST3")
&& (plugin.uri == pending_path || plugin.plugin_id == pending_path)
})
.map(|plugin| plugin.instance_id)
{
self.pending_add_vst3_automation_paths
.remove(&(pending_track.clone(), pending_path));
self.pending_add_vst3_automation_instances
.insert((pending_track.clone(), instance_id));
pending_queries.push(self.send(Action::TrackGetVst3Parameters {
track_name: pending_track,
instance_id,
}));
}
}
let pending_paths: Vec<(String, String)> = self
.pending_add_clap_automation_paths
.iter()
.filter(|(name, _)| name == track_name)
.cloned()
.collect();
for (pending_track, pending_path) in pending_paths {
if let Some(instance_id) = plugins
.iter()
.find(|plugin| {
plugin.format.eq_ignore_ascii_case("CLAP")
&& (plugin.uri == pending_path || plugin.plugin_id == pending_path)
})
.map(|plugin| plugin.instance_id)
{
self.pending_add_clap_automation_paths
.remove(&(pending_track.clone(), pending_path));
self.pending_add_clap_automation_instances
.insert((pending_track.clone(), instance_id));
pending_queries.push(self.send(Action::TrackGetClapParameters {
track_name: pending_track,
instance_id,
}));
}
}
pending_queries
}
#[cfg(all(unix, not(target_os = "macos")))]
fn queue_pending_lv2_automation_queries(
&mut self,
track_name: &str,
plugins: &[PluginGraphPlugin],
pending_queries: &mut Vec<Task<Message>>,
) {
let pending_lv2_uris: Vec<(String, String)> = self
.pending_add_lv2_automation_uris
.iter()
.filter(|(name, _)| name == track_name)
.cloned()
.collect();
for (pending_track, pending_uri) in pending_lv2_uris {
if let Some(instance_id) = plugins
.iter()
.find(|plugin| {
plugin.format.eq_ignore_ascii_case("LV2")
&& (plugin.uri == pending_uri || plugin.plugin_id == pending_uri)
})
.map(|plugin| plugin.instance_id)
{
self.pending_add_lv2_automation_uris
.remove(&(pending_track.clone(), pending_uri));
self.pending_add_lv2_automation_instances
.insert((pending_track.clone(), instance_id));
pending_queries.push(self.send(Action::TrackGetLv2PluginControls {
track_name: pending_track,
instance_id,
}));
}
}
}
fn pending_save_ready(&self) -> bool {
self.pending_save_tracks.is_empty()
&& self.pending_save_clap_tracks.is_empty()
&& self.pending_save_clap_clips.is_empty()
&& self.pending_vst3_save_ready()
}
pub(super) fn complete_pending_save(&mut self, track_name: &str) -> Option<Task<Message>> {
if !self.pending_save_ready() {
return None;
}
let path = self.pending_save_path.take()?;
let is_template = self.pending_save_is_template;
self.pending_save_is_template = false;
if path.is_empty() {
return None;
}
if is_template {
if let Err(e) = self.save_template(path.clone()) {
self.state.blocking_write().message = format!("Failed to save template: {}", e);
} else {
self.state.blocking_write().message = "Template saved".to_string();
let templates = crate::gui::scan_templates();
self.state.blocking_write().available_templates = templates.clone();
self.menu.update_templates(templates);
}
} else if path.contains("/track_templates/") {
let save_track_name = self
.pending_save_track_name
.take()
.unwrap_or_else(|| track_name.to_string());
return Some(self.save_track_as_template(&save_track_name, path));
} else if let Err(e) = self.save(path.clone()) {
self.pending_exit_after_save = false;
self.state.blocking_write().message = format!("Failed to save session: {}", e);
} else {
return Some(Task::batch(vec![
self.send(Action::MarkHistorySavePoint),
self.send(Action::SetSessionPath(path)),
]));
}
None
}
#[cfg(target_os = "macos")]
fn pending_vst3_save_ready(&self) -> bool {
!platform_caps::REQUIRE_VST3_STATE_FOR_SAVE || self.pending_save_vst3_states.is_empty()
}
#[cfg(not(target_os = "macos"))]
fn pending_vst3_save_ready(&self) -> bool {
!platform_caps::REQUIRE_VST3_STATE_FOR_SAVE
}
fn rename_track_map_entry<T>(map: &mut HashMap<String, T>, old_name: &str, new_name: &str) {
if let Some(value) = map.remove(old_name) {
map.insert(new_name.to_string(), value);
}
}
fn rebuild_midi_mappings_report_lines_from_state(&mut self) {
let state = self.state.blocking_read();
let mut lines = Vec::<String>::new();
let mut push_binding =
|scope: String, label: &str, binding: &maolan_engine::message::MidiLearnBinding| {
lines.push(format!(
"{scope} {label}: CH{} CC{}",
binding.channel + 1,
binding.cc
));
};
if let Some(binding) = state.global_midi_learn_play_pause.as_ref() {
push_binding("Global".to_string(), "Play/Pause", binding);
}
if let Some(binding) = state.global_midi_learn_stop.as_ref() {
push_binding("Global".to_string(), "Stop", binding);
}
if let Some(binding) = state.global_midi_learn_record_toggle.as_ref() {
push_binding("Global".to_string(), "Record Toggle", binding);
}
for track in &state.tracks {
if let Some(binding) = track.midi_learn_volume.as_ref() {
push_binding(format!("Track '{}'", track.name), "Volume", binding);
}
if let Some(binding) = track.midi_learn_balance.as_ref() {
push_binding(format!("Track '{}'", track.name), "Balance", binding);
}
if let Some(binding) = track.midi_learn_mute.as_ref() {
push_binding(format!("Track '{}'", track.name), "Mute", binding);
}
if let Some(binding) = track.midi_learn_solo.as_ref() {
push_binding(format!("Track '{}'", track.name), "Solo", binding);
}
if let Some(binding) = track.midi_learn_arm.as_ref() {
push_binding(format!("Track '{}'", track.name), "Arm", binding);
}
if let Some(binding) = track.midi_learn_input_monitor.as_ref() {
push_binding(format!("Track '{}'", track.name), "Input Monitor", binding);
}
if let Some(binding) = track.midi_learn_disk_monitor.as_ref() {
push_binding(format!("Track '{}'", track.name), "Disk Monitor", binding);
}
}
for ((track_name, scene_index), binding) in &state.session_midi_learn_slots {
push_binding(
format!("Track '{}'", track_name),
&format!("Slot {}", scene_index + 1),
binding,
);
}
for (scene_index, binding) in &state.session_midi_learn_scenes {
push_binding(
"Session".to_string(),
&format!("Scene {}", scene_index + 1),
binding,
);
}
for (track_name, binding) in &state.session_midi_learn_stop_track {
push_binding(format!("Track '{}'", track_name), "Stop Track", binding);
}
if let Some(binding) = state.session_midi_learn_stop_all.as_ref() {
push_binding("Session".to_string(), "Stop All Clips", binding);
}
if lines.is_empty() {
lines.push("No MIDI learn bindings".to_string());
}
self.midi_mappings_report_lines = lines;
}
fn assign_take_lanes<T, FBase, FStart, FLen, FPreferred>(
clips: &[T],
base_lane: FBase,
start_sample: FStart,
length_samples: FLen,
preferred_take_lane: FPreferred,
) -> (Vec<usize>, Vec<usize>)
where
FBase: Fn(&T) -> usize,
FStart: Fn(&T) -> usize,
FLen: Fn(&T) -> usize,
FPreferred: Fn(&T) -> Option<usize>,
{
let mut take_index_by_clip = vec![0_usize; clips.len()];
let mut max_takes_by_lane: HashMap<usize, usize> = HashMap::new();
let mut active_by_lane: HashMap<usize, Vec<(usize, usize)>> = HashMap::new();
let mut order: Vec<usize> = (0..clips.len()).collect();
order.sort_by_key(|idx| {
let clip = &clips[*idx];
(base_lane(clip), start_sample(clip), std::cmp::Reverse(*idx))
});
for idx in order {
let clip = &clips[idx];
let lane = base_lane(clip);
let start = start_sample(clip);
let end = start.saturating_add(length_samples(clip));
let active = active_by_lane.entry(lane).or_default();
active.retain(|(existing_end, _)| *existing_end > start);
let preferred = preferred_take_lane(clip);
let mut take_idx = preferred.unwrap_or(0);
if preferred.is_none() {
while active
.iter()
.any(|(_, existing_take)| *existing_take == take_idx)
{
take_idx = take_idx.saturating_add(1);
}
}
active.push((end, take_idx));
take_index_by_clip[idx] = take_idx;
max_takes_by_lane
.entry(lane)
.and_modify(|max_take| *max_take = (*max_take).max(take_idx.saturating_add(1)))
.or_insert(take_idx.saturating_add(1));
}
let take_count_by_clip = clips
.iter()
.map(|clip| {
let lane = base_lane(clip);
max_takes_by_lane.get(&lane).copied().unwrap_or(1).max(1)
})
.collect::<Vec<_>>();
(take_index_by_clip, take_count_by_clip)
}
fn timing_at_sample(state: &crate::state::StateData, sample: usize) -> (f32, u8, u8) {
let bpm = state
.tempo_points
.iter()
.filter(|p| p.sample <= sample)
.max_by_key(|p| p.sample)
.map(|p| p.bpm)
.unwrap_or(state.tempo)
.clamp(20.0, 300.0);
let (num, den) = state
.time_signature_points
.iter()
.filter(|p| p.sample <= sample)
.max_by_key(|p| p.sample)
.map(|p| (p.numerator.max(1), p.denominator.max(1)))
.unwrap_or((
state.time_signature_num.max(1),
state.time_signature_denom.max(1),
));
(bpm, num, den)
}
fn sync_timing_inputs_from_selection(&mut self) {
let state = self.state.blocking_read();
if let Some(sample) = self.selected_tempo_points.iter().next().copied()
&& let Some(point) = state.tempo_points.iter().find(|p| p.sample == sample)
{
self.tempo_input = format!("{:.2}", point.bpm);
}
if let Some(sample) = self.selected_time_signature_points.iter().next().copied()
&& let Some(point) = state
.time_signature_points
.iter()
.find(|p| p.sample == sample)
{
self.time_signature_num_input = point.numerator.to_string();
self.time_signature_denom_input = point.denominator.to_string();
}
}
fn selected_piano_notes_edit<F>(&mut self, mut edit: F) -> Task<Message>
where
F: FnMut(
usize,
&maolan_engine::message::MidiNoteData,
) -> maolan_engine::message::MidiNoteData,
{
let mut state = self.state.blocking_write();
if !matches!(state.view, View::Piano) {
return Task::none();
}
let mut selected_indices: Vec<usize> = state.piano_selected_notes.iter().copied().collect();
selected_indices.sort_unstable();
selected_indices.dedup();
if selected_indices.is_empty() {
return Task::none();
}
let Some(piano) = state.piano.as_mut() else {
return Task::none();
};
let track_name = piano.track_idx.clone();
let clip_idx = piano.clip_index;
let mut changed_indices = Vec::new();
let mut new_notes = Vec::new();
let mut old_notes = Vec::new();
for idx in selected_indices {
let Some(note) = piano.notes.get_mut(idx) else {
continue;
};
let old_note = maolan_engine::message::MidiNoteData {
start_sample: note.start_sample,
length_samples: note.length_samples,
pitch: note.pitch,
velocity: note.velocity,
channel: note.channel,
};
let mut new_note = edit(idx, &old_note);
if new_note.length_samples == 0 {
new_note.length_samples = 1;
}
if new_note.start_sample == old_note.start_sample
&& new_note.length_samples == old_note.length_samples
&& new_note.pitch == old_note.pitch
&& new_note.velocity == old_note.velocity
&& new_note.channel == old_note.channel
{
continue;
}
note.start_sample = new_note.start_sample;
note.length_samples = new_note.length_samples;
note.pitch = new_note.pitch;
note.velocity = new_note.velocity;
note.channel = new_note.channel;
changed_indices.push(idx);
new_notes.push(new_note);
old_notes.push(old_note);
}
if changed_indices.is_empty() {
return Task::none();
}
drop(state);
self.send(Action::ModifyMidiNotes {
track_name,
clip_index: clip_idx,
note_indices: changed_indices,
new_notes,
old_notes,
})
}
fn queue_midi_clip_preview_loads(&mut self) -> Task<Message> {
let Some(session_dir) = self.session_dir.clone() else {
return Task::none();
};
let mut live = HashMap::<(String, usize), (String, usize)>::new();
{
let state = self.state.blocking_read();
for track in &state.tracks {
for (clip_idx, clip) in track.midi.clips.iter().enumerate() {
live.insert(
(track.name.clone(), clip_idx),
(clip.name.clone(), clip.start),
);
}
}
}
self.midi_clip_previews
.retain(|key, _| live.contains_key(key));
self.pending_midi_clip_previews
.retain(|(track_name, clip_idx, clip_name)| {
live.get(&(track_name.clone(), *clip_idx))
.is_some_and(|(name, _)| name == clip_name)
});
let mut tasks = Vec::new();
for ((track_name, clip_idx), (clip_name, clip_start)) in live {
self.midi_clip_previews
.remove(&(track_name.clone(), clip_idx));
let pending_key = (track_name.clone(), clip_idx, clip_name.clone());
if !self.pending_midi_clip_previews.insert(pending_key) {
continue;
}
let session_dir = session_dir.clone();
let playback_rate_hz = self.playback_rate_hz;
let task_clip = clip_name.clone();
let result_track = track_name.clone();
let result_clip = clip_name.clone();
tasks.push(Task::perform(
async move {
let clip_path = std::path::PathBuf::from(&task_clip);
let path = if clip_path.is_absolute() {
clip_path
} else {
session_dir.join(&task_clip)
};
match Self::parse_midi_clip_for_piano(&path, playback_rate_hz, clip_start) {
Ok((notes, _, _, _)) => notes,
Err(_) => Vec::new(),
}
},
move |notes| Message::MidiClipPreviewLoaded {
track_idx: result_track.clone(),
clip_idx,
clip_name: result_clip.clone(),
notes,
},
));
}
if tasks.is_empty() {
Task::none()
} else {
Task::batch(tasks)
}
}
fn deterministic_note_jitter(seed_a: usize, seed_b: usize, amplitude: i64) -> i64 {
if amplitude <= 0 {
return 0;
}
let mut x = (seed_a as u64)
.wrapping_mul(0x9E37_79B9_7F4A_7C15)
.wrapping_add((seed_b as u64).wrapping_mul(0xBF58_476D_1CE4_E5B9))
.wrapping_add(0x94D0_49BB_1331_11EB);
x ^= x >> 30;
x = x.wrapping_mul(0xBF58_476D_1CE4_E5B9);
x ^= x >> 27;
x = x.wrapping_mul(0x94D0_49BB_1331_11EB);
x ^= x >> 31;
let range = (amplitude as u64).saturating_mul(2).saturating_add(1);
(x % range) as i64 - amplitude
}
fn nearest_scale_pitch(pitch: u8, root_semitone: u8, minor: bool) -> u8 {
let pattern: &[u8] = if minor {
&[0, 2, 3, 5, 7, 8, 10]
} else {
&[0, 2, 4, 5, 7, 9, 11]
};
let mut best = pitch;
let mut best_dist = i16::MAX;
for candidate in 0_u8..=127_u8 {
let rel = (candidate as i16 - root_semitone as i16).rem_euclid(12) as u8;
if !pattern.contains(&rel) {
continue;
}
let dist = (candidate as i16 - pitch as i16).abs();
if dist < best_dist || (dist == best_dist && candidate < best) {
best = candidate;
best_dist = dist;
}
}
best
}
fn automation_key(target: TrackAutomationTarget) -> AutomationWriteKey {
match target {
TrackAutomationTarget::Volume => AutomationWriteKey::Volume,
TrackAutomationTarget::Balance => AutomationWriteKey::Balance,
TrackAutomationTarget::MidiCc { channel, cc } => {
AutomationWriteKey::MidiCc { channel, cc }
}
TrackAutomationTarget::Lv2Parameter {
instance_id, index, ..
} => AutomationWriteKey::Lv2 { instance_id, index },
TrackAutomationTarget::Vst3Parameter {
instance_id,
param_id,
} => AutomationWriteKey::Vst3 {
instance_id,
param_id,
},
TrackAutomationTarget::ClapParameter {
instance_id,
param_id,
..
} => AutomationWriteKey::Clap {
instance_id,
param_id,
},
}
}
fn key_has_explicit_gesture_lifecycle(key: AutomationWriteKey) -> bool {
matches!(key, AutomationWriteKey::Clap { .. })
}
fn record_automation_point(
&mut self,
track_name: &str,
target: TrackAutomationTarget,
value: f32,
) {
if !self.playing || self.paused {
return;
}
let sample = self.transport_samples.max(0.0) as usize;
let mut state = self.state.blocking_write();
let Some(track) = state.tracks.iter_mut().find(|t| t.name == track_name) else {
return;
};
if track.automation_mode == TrackAutomationMode::Read {
return;
}
let previous_lane_height = track
.lane_layout()
.representative_height()
.max(TRACK_SUBTRACK_MIN_HEIGHT);
let previously_visible = track.automation_lane_count();
if let Some(lane) = track
.automation_lanes
.iter_mut()
.find(|lane| lane.target == target)
{
if let Some(existing) = lane.points.iter_mut().find(|p| p.sample == sample) {
existing.value = value.clamp(0.0, 1.0);
} else {
lane.points.push(TrackAutomationPoint {
sample,
value: value.clamp(0.0, 1.0),
});
lane.points.sort_unstable_by_key(|p| p.sample);
}
lane.visible = true;
} else {
track
.automation_lanes
.push(crate::state::TrackAutomationLane {
target,
visible: true,
points: vec![TrackAutomationPoint {
sample,
value: value.clamp(0.0, 1.0),
}],
});
}
let lanes_delta = track.automation_lane_count() as isize - previously_visible as isize;
track.adjust_height_for_automation_lanes(previous_lane_height, lanes_delta);
}
fn record_manual_override(
&mut self,
track_name: &str,
target: TrackAutomationTarget,
value: f32,
) {
let mode = {
let state = self.state.blocking_read();
state
.tracks
.iter()
.find(|t| t.name == track_name)
.map(|t| t.automation_mode)
};
let Some(mode) = mode else {
return;
};
let key = Self::automation_key(target);
let value = value.clamp(0.0, 1.0);
match mode {
TrackAutomationMode::Read | TrackAutomationMode::Write => {}
TrackAutomationMode::Touch => {
let key = Self::automation_key(target);
self.touch_automation_overrides
.entry(track_name.to_string())
.or_default()
.insert(
key,
TouchAutomationOverride {
value,
updated_at: Instant::now(),
},
);
self.touch_active_keys
.entry(track_name.to_string())
.or_default()
.insert(key);
}
TrackAutomationMode::Latch => {
self.latch_automation_overrides
.entry(track_name.to_string())
.or_default()
.insert(key, value);
}
}
}
fn begin_touch_gesture(&mut self, track_name: &str, key: AutomationWriteKey) {
let mode = {
let state = self.state.blocking_read();
state
.tracks
.iter()
.find(|t| t.name == track_name)
.map(|t| t.automation_mode)
};
if mode == Some(TrackAutomationMode::Touch) {
self.touch_active_keys
.entry(track_name.to_string())
.or_default()
.insert(key);
}
}
fn end_touch_gesture(&mut self, track_name: &str, key: AutomationWriteKey) {
if let Some(active) = self.touch_active_keys.get_mut(track_name) {
active.remove(&key);
if active.is_empty() {
self.touch_active_keys.remove(track_name);
}
}
if let Some(values) = self.touch_automation_overrides.get_mut(track_name) {
values.remove(&key);
if values.is_empty() {
self.touch_automation_overrides.remove(track_name);
}
}
}
fn find_clap_target(
&self,
track_name: &str,
instance_id: usize,
param_id: u32,
) -> Option<TrackAutomationTarget> {
let state = self.state.blocking_read();
let track = state.tracks.iter().find(|t| t.name == track_name)?;
track
.automation_lanes
.iter()
.find_map(|lane| match lane.target {
TrackAutomationTarget::ClapParameter {
instance_id: i,
param_id: p,
min,
max,
} if i == instance_id && p == param_id => {
Some(TrackAutomationTarget::ClapParameter {
instance_id: i,
param_id: p,
min,
max,
})
}
_ => None,
})
}
#[cfg(all(unix, not(target_os = "macos")))]
fn find_lv2_target(
&self,
track_name: &str,
instance_id: usize,
index: u32,
) -> Option<TrackAutomationTarget> {
let state = self.state.blocking_read();
let track = state.tracks.iter().find(|t| t.name == track_name)?;
track
.automation_lanes
.iter()
.find_map(|lane| match lane.target {
TrackAutomationTarget::Lv2Parameter {
instance_id: i,
index: c,
min,
max,
} if i == instance_id && c == index => Some(TrackAutomationTarget::Lv2Parameter {
instance_id: i,
index: c,
min,
max,
}),
_ => None,
})
}
fn is_track_frozen(&self, track_name: &str) -> bool {
let state = self.state.blocking_read();
state
.tracks
.iter()
.find(|t| t.name == track_name)
.is_some_and(|t| t.frozen)
}
fn midi_mappings_path(&self) -> Option<std::path::PathBuf> {
self.session_dir
.as_ref()
.map(|root| root.join("midi_mappings.json"))
}
fn export_midi_mappings_file(&self) -> Result<std::path::PathBuf, String> {
let Some(path) = self.midi_mappings_path() else {
return Err("Export MIDI mappings requires an opened/saved session".to_string());
};
let state = self.state.blocking_read();
let mut tracks = HashMap::<String, MidiMappingsTrackFile>::new();
for track in &state.tracks {
tracks.insert(
track.name.clone(),
MidiMappingsTrackFile {
volume: track.midi_learn_volume.clone(),
balance: track.midi_learn_balance.clone(),
mute: track.midi_learn_mute.clone(),
solo: track.midi_learn_solo.clone(),
arm: track.midi_learn_arm.clone(),
input_monitor: track.midi_learn_input_monitor.clone(),
disk_monitor: track.midi_learn_disk_monitor.clone(),
},
);
}
let mut session_slots =
HashMap::<String, HashMap<usize, maolan_engine::message::MidiLearnBinding>>::new();
for ((track_name, scene_index), binding) in &state.session_midi_learn_slots {
session_slots
.entry(track_name.clone())
.or_default()
.insert(*scene_index, binding.clone());
}
let file = MidiMappingsFile {
global: MidiMappingsGlobalFile {
play_pause: state.global_midi_learn_play_pause.clone(),
stop: state.global_midi_learn_stop.clone(),
record_toggle: state.global_midi_learn_record_toggle.clone(),
},
tracks,
session_slots,
session_scenes: state.session_midi_learn_scenes.clone(),
session_stop_track: state.session_midi_learn_stop_track.clone(),
session_stop_all: state.session_midi_learn_stop_all.clone(),
};
let f = std::fs::File::create(&path).map_err(|e| e.to_string())?;
serde_json::to_writer_pretty(f, &file).map_err(|e| e.to_string())?;
Ok(path)
}
fn import_midi_mappings_actions(&self) -> Result<Vec<Action>, String> {
let Some(path) = self.midi_mappings_path() else {
return Err("Import MIDI mappings requires an opened/saved session".to_string());
};
let f = std::fs::File::open(&path).map_err(|e| e.to_string())?;
let file: MidiMappingsFile = serde_json::from_reader(f).map_err(|e| e.to_string())?;
let state = self.state.blocking_read();
let mut actions = Vec::<Action>::new();
actions.push(Action::SetGlobalMidiLearnBinding {
target: maolan_engine::message::GlobalMidiLearnTarget::PlayPause,
binding: None,
});
actions.push(Action::SetGlobalMidiLearnBinding {
target: maolan_engine::message::GlobalMidiLearnTarget::Stop,
binding: None,
});
actions.push(Action::SetGlobalMidiLearnBinding {
target: maolan_engine::message::GlobalMidiLearnTarget::RecordToggle,
binding: None,
});
for track in &state.tracks {
let name = track.name.clone();
for target in [
maolan_engine::message::TrackMidiLearnTarget::Volume,
maolan_engine::message::TrackMidiLearnTarget::Balance,
maolan_engine::message::TrackMidiLearnTarget::Mute,
maolan_engine::message::TrackMidiLearnTarget::Solo,
maolan_engine::message::TrackMidiLearnTarget::Arm,
maolan_engine::message::TrackMidiLearnTarget::InputMonitor,
maolan_engine::message::TrackMidiLearnTarget::DiskMonitor,
] {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target,
binding: None,
});
}
}
if file.global.play_pause.is_some() {
actions.push(Action::SetGlobalMidiLearnBinding {
target: maolan_engine::message::GlobalMidiLearnTarget::PlayPause,
binding: file.global.play_pause,
});
}
if file.global.stop.is_some() {
actions.push(Action::SetGlobalMidiLearnBinding {
target: maolan_engine::message::GlobalMidiLearnTarget::Stop,
binding: file.global.stop,
});
}
if file.global.record_toggle.is_some() {
actions.push(Action::SetGlobalMidiLearnBinding {
target: maolan_engine::message::GlobalMidiLearnTarget::RecordToggle,
binding: file.global.record_toggle,
});
}
for (track_name, mapping) in file.tracks {
if !state.tracks.iter().any(|t| t.name == track_name) {
continue;
}
let mut push_if_some =
|target: maolan_engine::message::TrackMidiLearnTarget,
binding: Option<maolan_engine::message::MidiLearnBinding>| {
if binding.is_some() {
actions.push(Action::TrackSetMidiLearnBinding {
track_name: track_name.clone(),
target,
binding,
});
}
};
push_if_some(
maolan_engine::message::TrackMidiLearnTarget::Volume,
mapping.volume,
);
push_if_some(
maolan_engine::message::TrackMidiLearnTarget::Balance,
mapping.balance,
);
push_if_some(
maolan_engine::message::TrackMidiLearnTarget::Mute,
mapping.mute,
);
push_if_some(
maolan_engine::message::TrackMidiLearnTarget::Solo,
mapping.solo,
);
push_if_some(
maolan_engine::message::TrackMidiLearnTarget::Arm,
mapping.arm,
);
push_if_some(
maolan_engine::message::TrackMidiLearnTarget::InputMonitor,
mapping.input_monitor,
);
push_if_some(
maolan_engine::message::TrackMidiLearnTarget::DiskMonitor,
mapping.disk_monitor,
);
}
for (track_name, slots) in file.session_slots {
if !state.tracks.iter().any(|t| t.name == track_name) {
continue;
}
for (scene_index, binding) in slots {
actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::Slot {
track_name: track_name.clone(),
scene_index,
},
binding: Some(binding),
});
}
}
for (scene_index, binding) in file.session_scenes {
actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::Scene(scene_index),
binding: Some(binding),
});
}
for (track_name, binding) in file.session_stop_track {
if !state.tracks.iter().any(|t| t.name == track_name) {
continue;
}
actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::StopTrack(track_name),
binding: Some(binding),
});
}
if let Some(binding) = file.session_stop_all {
actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::StopAll,
binding: Some(binding),
});
}
Ok(actions)
}
fn update_visible_controller_value(
&mut self,
track_name: &str,
instance_id: usize,
param_id: u32,
value: f32,
) {
let mut state = self.state.blocking_write();
if let Some(controller) = state
.plugin_graph_visible_controllers
.get_mut(track_name)
.and_then(|m| m.get_mut(&instance_id))
.and_then(|cs| cs.iter_mut().find(|c| c.param_id == param_id))
{
controller.value = value;
}
}
fn maybe_record_automation_from_request(&mut self, action: &Action) {
match action {
Action::TrackLevel(track_name, level) if track_name != "hw:out" => {
let normalized = ((*level + 90.0) / 110.0).clamp(0.0, 1.0);
self.record_automation_point(track_name, TrackAutomationTarget::Volume, normalized);
self.record_manual_override(track_name, TrackAutomationTarget::Volume, normalized);
}
Action::TrackBalance(track_name, balance) if track_name != "hw:out" => {
let normalized = ((*balance + 1.0) * 0.5).clamp(0.0, 1.0);
self.record_automation_point(
track_name,
TrackAutomationTarget::Balance,
normalized,
);
self.record_manual_override(track_name, TrackAutomationTarget::Balance, normalized);
}
Action::TrackSetVst3Parameter {
track_name,
instance_id,
param_id,
value,
} => {
if self.is_track_frozen(track_name) {
return;
}
let normalized = (*value).clamp(0.0, 1.0);
let (min, max) = {
let state = self.state.blocking_read();
state
.plugin_parameters_by_track
.get(track_name)
.and_then(|p| p.get(instance_id))
.and_then(|params| params.iter().find(|p| p.param_id == *param_id))
.map(|p| (p.min as f32, p.max as f32))
.unwrap_or((0.0, 1.0))
};
let actual = min + normalized * (max - min);
self.update_visible_controller_value(track_name, *instance_id, *param_id, actual);
self.record_automation_point(
track_name,
TrackAutomationTarget::Vst3Parameter {
instance_id: *instance_id,
param_id: *param_id,
},
normalized,
);
self.record_manual_override(
track_name,
TrackAutomationTarget::Vst3Parameter {
instance_id: *instance_id,
param_id: *param_id,
},
normalized,
);
}
Action::TrackSetClapParameter {
track_name,
instance_id,
param_id,
value,
}
| Action::TrackSetClapParameterAt {
track_name,
instance_id,
param_id,
value,
..
} => {
if self.is_track_frozen(track_name) {
return;
}
self.update_visible_controller_value(
track_name,
*instance_id,
*param_id,
*value as f32,
);
if let Some(TrackAutomationTarget::ClapParameter { min, max, .. }) =
self.find_clap_target(track_name, *instance_id, *param_id)
{
let span = (max - min).abs();
let normalized = if span <= f64::EPSILON {
0.0
} else {
((*value - min) / (max - min)).clamp(0.0, 1.0)
} as f32;
self.record_automation_point(
track_name,
TrackAutomationTarget::ClapParameter {
instance_id: *instance_id,
param_id: *param_id,
min,
max,
},
normalized,
);
self.record_manual_override(
track_name,
TrackAutomationTarget::ClapParameter {
instance_id: *instance_id,
param_id: *param_id,
min,
max,
},
normalized,
);
}
}
Action::TrackBeginClapParameterEdit {
track_name,
instance_id,
param_id,
..
} => {
if self.is_track_frozen(track_name) {
return;
}
self.begin_touch_gesture(
track_name,
AutomationWriteKey::Clap {
instance_id: *instance_id,
param_id: *param_id,
},
);
}
Action::TrackEndClapParameterEdit {
track_name,
instance_id,
param_id,
..
} => {
if self.is_track_frozen(track_name) {
return;
}
self.end_touch_gesture(
track_name,
AutomationWriteKey::Clap {
instance_id: *instance_id,
param_id: *param_id,
},
);
}
#[cfg(all(unix, not(target_os = "macos")))]
Action::TrackSetLv2ControlValue {
track_name,
instance_id,
index,
value,
} => {
if self.is_track_frozen(track_name) {
return;
}
self.update_visible_controller_value(track_name, *instance_id, *index, *value);
if let Some(TrackAutomationTarget::Lv2Parameter { min, max, .. }) =
self.find_lv2_target(track_name, *instance_id, *index)
{
let span = (max - min).abs();
let normalized = if span <= f32::EPSILON {
0.0
} else {
((*value - min) / (max - min)).clamp(0.0, 1.0)
};
self.record_automation_point(
track_name,
TrackAutomationTarget::Lv2Parameter {
instance_id: *instance_id,
index: *index,
min,
max,
},
normalized,
);
self.record_manual_override(
track_name,
TrackAutomationTarget::Lv2Parameter {
instance_id: *instance_id,
index: *index,
min,
max,
},
normalized,
);
}
}
_ => {}
}
}
fn folder_children_for(&self, folder_name: &str) -> Vec<String> {
let state = self.state.blocking_read();
let mut children = Vec::new();
let mut to_process = vec![folder_name.to_string()];
while let Some(parent) = to_process.pop() {
for track in &state.tracks {
if track.parent_track.as_deref() == Some(&parent) {
children.push(track.name.clone());
if track.is_folder {
to_process.push(track.name.clone());
}
}
}
}
children
}
fn expand_request_to_folder_children(&self, action: &Action) -> Option<Vec<Action>> {
let (source_track, builder): (&str, fn(String, &Action) -> Action) = match action {
Action::TrackToggleSolo(track_name) => {
(track_name.as_str(), |name, _| Action::TrackToggleSolo(name))
}
_ => return None,
};
let state = self.state.blocking_read();
let is_folder = state
.tracks
.iter()
.any(|t| t.name == source_track && t.is_folder);
drop(state);
if !is_folder {
return None;
}
let children = self.folder_children_for(source_track);
if children.is_empty() {
return None;
}
let mut expanded = vec![builder(source_track.to_string(), action)];
expanded.extend(children.into_iter().map(|name| builder(name, action)));
Some(expanded)
}
fn automation_lane_value_at(points: &[TrackAutomationPoint], sample: usize) -> Option<f32> {
if points.is_empty() {
return None;
}
let mut sorted: Vec<&TrackAutomationPoint> = points.iter().collect();
sorted.sort_unstable_by_key(|p| p.sample);
if sample <= sorted[0].sample {
return Some(sorted[0].value.clamp(0.0, 1.0));
}
if sample >= sorted[sorted.len().saturating_sub(1)].sample {
return Some(sorted[sorted.len().saturating_sub(1)].value.clamp(0.0, 1.0));
}
for segment in sorted.windows(2) {
let left = segment[0];
let right = segment[1];
if sample < left.sample || sample > right.sample {
continue;
}
let span = right.sample.saturating_sub(left.sample).max(1) as f32;
let t = (sample.saturating_sub(left.sample) as f32 / span).clamp(0.0, 1.0);
let value = left.value + (right.value - left.value) * t;
return Some(value.clamp(0.0, 1.0));
}
None
}
fn collect_track_automation_actions(
&mut self,
sample: usize,
tracks: &[AutomationTrackView],
) -> Vec<Action> {
let now = Instant::now();
for (track_name, active_keys) in self.touch_active_keys.iter_mut() {
let values = self.touch_automation_overrides.get(track_name);
active_keys.retain(|key| {
if Self::key_has_explicit_gesture_lifecycle(*key) {
true
} else {
values.and_then(|map| map.get(key)).is_some_and(|entry| {
now.duration_since(entry.updated_at) <= Duration::from_millis(220)
})
}
});
}
self.touch_active_keys.retain(|_, keys| !keys.is_empty());
for (track_name, values) in self.touch_automation_overrides.iter_mut() {
let active = self.touch_active_keys.get(track_name);
values.retain(|key, entry| {
active.is_some_and(|set| set.contains(key))
|| now.duration_since(entry.updated_at) <= Duration::from_millis(220)
});
}
self.touch_automation_overrides
.retain(|_, values| !values.is_empty());
let mut actions = Vec::new();
for track in tracks {
if track.automation_mode == TrackAutomationMode::Write {
continue;
}
let mut vol = None;
let mut bal = None;
let mut midi_cc_updates: Vec<(u8, u8, u8)> = Vec::new();
let runtime = self
.track_automation_runtime
.entry(track.name.clone())
.or_default();
for lane in &track.automation_lanes {
let key = Self::automation_key(lane.target);
let override_value = match track.automation_mode {
TrackAutomationMode::Touch => self
.touch_automation_overrides
.get(&track.name)
.and_then(|values| values.get(&key))
.and_then(|entry| {
let active = self
.touch_active_keys
.get(&track.name)
.is_some_and(|set| set.contains(&key));
let fresh =
now.duration_since(entry.updated_at) <= Duration::from_millis(220);
(active || fresh).then_some(entry.value)
}),
TrackAutomationMode::Latch => self
.latch_automation_overrides
.get(&track.name)
.and_then(|values| values.get(&key))
.copied(),
_ => None,
};
let value =
override_value.or_else(|| Self::automation_lane_value_at(&lane.points, sample));
match lane.target {
TrackAutomationTarget::Volume => vol = value,
TrackAutomationTarget::Balance => bal = value,
TrackAutomationTarget::MidiCc { channel, cc } => {
if let Some(v) = value {
let cc_value = (v * 127.0).round() as u8;
midi_cc_updates.push((channel, cc, cc_value));
}
}
#[cfg(all(unix, not(target_os = "macos")))]
TrackAutomationTarget::Lv2Parameter {
instance_id,
index,
min,
max,
} => {
if track.frozen {
continue;
}
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(v) = value {
let lo = min.min(max);
let hi = max.max(min);
let param_value = (lo + v * (hi - lo)).clamp(lo, hi);
let key = (instance_id, index);
if runtime
.lv2_params
.get(&key)
.is_none_or(|current| (current - param_value).abs() >= 0.0005)
{
runtime.lv2_params.insert(key, param_value);
actions.push(Action::TrackSetLv2ControlValue {
track_name: track.name.clone(),
instance_id,
index,
value: param_value,
});
}
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
TrackAutomationTarget::Lv2Parameter { .. } => {}
TrackAutomationTarget::Vst3Parameter {
instance_id,
param_id,
} => {
if track.frozen {
continue;
}
if let Some(v) = value {
let param_value = v.clamp(0.0, 1.0);
let key = (instance_id, param_id);
if runtime
.vst3_params
.get(&key)
.is_none_or(|current| (current - param_value).abs() >= 0.0005)
{
runtime.vst3_params.insert(key, param_value);
actions.push(Action::TrackSetVst3Parameter {
track_name: track.name.clone(),
instance_id,
param_id,
value: param_value,
});
}
}
}
TrackAutomationTarget::ClapParameter {
instance_id,
param_id,
min,
max,
} => {
if track.frozen {
continue;
}
if let Some(v) = value {
let lo = min.min(max);
let hi = max.max(min);
let param_value = (lo + v as f64 * (hi - lo)).clamp(lo, hi);
let key = (instance_id, param_id);
if runtime
.clap_params
.get(&key)
.is_none_or(|current| (current - param_value).abs() >= 0.0005)
{
runtime.clap_params.insert(key, param_value);
actions.push(Action::TrackSetClapParameterAt {
track_name: track.name.clone(),
instance_id,
param_id,
value: param_value,
frame: 0,
});
}
}
}
}
}
if let Some(v) = vol {
let level_db = (-90.0 + v * 110.0).clamp(-90.0, 20.0);
if runtime
.level_db
.is_none_or(|current| (current - level_db).abs() >= 0.1)
{
runtime.level_db = Some(level_db);
actions.push(Action::TrackAutomationLevel(track.name.clone(), level_db));
}
}
if let Some(v) = bal {
let balance = (v * 2.0 - 1.0).clamp(-1.0, 1.0);
if runtime
.balance
.is_none_or(|current| (current - balance).abs() >= 0.01)
{
runtime.balance = Some(balance);
actions.push(Action::TrackAutomationBalance(track.name.clone(), balance));
}
}
for (channel, cc, value) in midi_cc_updates {
let key = (channel, cc);
if runtime
.midi_cc
.get(&key)
.is_none_or(|current| *current != value)
{
runtime.midi_cc.insert(key, value);
actions.push(Action::TrackMidiCc {
track_name: track.name.clone(),
channel,
cc,
value,
});
}
}
}
actions
}
fn format_sysex_hex(data: &[u8]) -> String {
data.iter()
.map(|b| format!("{b:02X}"))
.collect::<Vec<_>>()
.join(" ")
}
fn parse_sysex_hex(raw: &str) -> Result<Vec<u8>, String> {
let mut out = Vec::new();
for token in raw
.split(|c: char| c.is_whitespace() || c == ',')
.filter(|s| !s.is_empty())
{
let normalized = token
.strip_prefix("0x")
.or_else(|| token.strip_prefix("0X"))
.unwrap_or(token);
let byte = u8::from_str_radix(normalized, 16)
.map_err(|_| format!("Invalid hex byte '{token}'"))?;
out.push(byte);
}
if out.is_empty() {
return Err("SysEx payload is empty".to_string());
}
if !matches!(out.first(), Some(0xF0) | Some(0xF7)) {
out.insert(0, 0xF0);
}
if out.first() == Some(&0xF0) && out.last() != Some(&0xF7) {
out.push(0xF7);
}
Ok(out)
}
fn sysex_to_engine(
points: &[PianoSysExPoint],
) -> Vec<maolan_engine::message::MidiRawEventData> {
points
.iter()
.map(|p| maolan_engine::message::MidiRawEventData {
sample: p.sample,
data: p.data.clone(),
})
.collect()
}
fn sync_editor_scrollbars(&self) -> Task<Message> {
let x = self.editor_scroll_relative_x();
let y = self.editor_scroll_y.clamp(0.0, 1.0);
Task::batch(vec![
operation::snap_to(
Id::new(EDITOR_SCROLL_ID),
operation::RelativeOffset {
x: None,
y: Some(y),
},
),
operation::snap_to(
Id::new(EDITOR_TIMELINE_SCROLL_ID),
operation::RelativeOffset {
x: Some(x),
y: None,
},
),
operation::snap_to(
Id::new(WORKSPACE_TEMPO_SCROLL_ID),
operation::RelativeOffset {
x: Some(x),
y: None,
},
),
operation::snap_to(
Id::new(WORKSPACE_RULER_SCROLL_ID),
operation::RelativeOffset {
x: Some(x),
y: None,
},
),
operation::snap_to(
Id::new(TRACKS_SCROLL_ID),
operation::RelativeOffset {
x: None,
y: Some(y),
},
),
])
}
fn sync_piano_scrollbars(&self) -> Task<Message> {
let (x, y) = {
let state = self.state.blocking_read();
(
state.piano_scroll_x.clamp(0.0, 1.0),
state.piano_scroll_y.clamp(0.0, 1.0),
)
};
Task::batch(vec![
operation::snap_to(
Id::new(NOTES_SCROLL_ID),
operation::RelativeOffset {
x: Some(x),
y: Some(y),
},
),
operation::snap_to(
Id::new(KEYS_SCROLL_ID),
operation::RelativeOffset {
x: None,
y: Some(y),
},
),
operation::snap_to(
Id::new(CTRL_SCROLL_ID),
operation::RelativeOffset {
x: Some(x),
y: None,
},
),
operation::snap_to(
Id::new(PIANO_TEMPO_SCROLL_ID),
operation::RelativeOffset {
x: Some(x),
y: None,
},
),
operation::snap_to(
Id::new(PIANO_RULER_SCROLL_ID),
operation::RelativeOffset {
x: Some(x),
y: None,
},
),
])
}
fn normalize_period_frames(period_frames: usize) -> usize {
let v = period_frames.clamp(1, 65536);
if v.is_power_of_two() {
v
} else {
v.next_power_of_two().min(65536)
}
}
pub(crate) fn apply_audio_cross_section_fades(
clips: &mut [crate::state::AudioClip],
inserted: &mut crate::state::AudioClip,
) {
let inserted_start = inserted.start;
let inserted_end = inserted.start.saturating_add(inserted.length);
let mut inserted_fade_in = None::<usize>;
let mut inserted_fade_out = None::<usize>;
for clip in clips
.iter_mut()
.filter(|clip| clip.input_channel == inserted.input_channel)
{
let clip_start = clip.start;
let clip_end = clip.start.saturating_add(clip.length);
let mut clip_fade_in = None::<usize>;
let mut clip_fade_out = None::<usize>;
let overlap_start = inserted_start.max(clip_start);
let overlap_end = inserted_end.min(clip_end);
if overlap_start >= overlap_end {
continue;
}
let overlap_len = overlap_end.saturating_sub(overlap_start);
if inserted_start == overlap_start && inserted_start != clip_start {
let fade = overlap_len.min(inserted.length);
inserted_fade_in = Some(inserted_fade_in.map_or(fade, |current| current.max(fade)));
}
if inserted_end == overlap_end && inserted_end != clip_end {
let fade = overlap_len.min(inserted.length);
inserted_fade_out =
Some(inserted_fade_out.map_or(fade, |current| current.max(fade)));
}
if clip_start == overlap_start && clip_start != inserted_start {
let fade = overlap_len.min(clip.length);
clip_fade_in = Some(clip_fade_in.map_or(fade, |current| current.max(fade)));
}
if clip_end == overlap_end && clip_end != inserted_end {
let fade = overlap_len.min(clip.length);
clip_fade_out = Some(clip_fade_out.map_or(fade, |current| current.max(fade)));
}
if let Some(fade_in) = clip_fade_in {
clip.fade_enabled = true;
clip.fade_in_samples = fade_in;
}
if let Some(fade_out) = clip_fade_out {
clip.fade_enabled = true;
clip.fade_out_samples = fade_out;
}
}
if let Some(fade_in) = inserted_fade_in {
inserted.fade_enabled = true;
inserted.fade_in_samples = fade_in;
}
if let Some(fade_out) = inserted_fade_out {
inserted.fade_enabled = true;
inserted.fade_out_samples = fade_out;
}
}
fn smooth_meter_db_levels(current: &mut Vec<f32>, target: &[f32]) {
const RELEASE_DB_PER_UPDATE: f32 = 12.0;
if current.len() != target.len() {
*current = target
.iter()
.copied()
.map(Self::quantize_meter_db)
.collect();
return;
}
for (cur, tgt) in current.iter_mut().zip(target.iter().copied()) {
let next = if tgt > *cur {
*cur + (tgt - *cur) * ATTACK_ALPHA
} else {
(*cur - RELEASE_DB_PER_UPDATE).max(tgt)
};
*cur = next.clamp(-90.0, 20.0);
}
}
fn midi_lane_at_position(&self, position: Point) -> Option<(String, usize)> {
let state = self.state.blocking_read();
let mut y_offset = 0.0f32;
for track in state
.tracks
.iter()
.filter(|track| track.name != METRONOME_TRACK_ID)
{
let track_top = y_offset;
let track_bottom = y_offset + track.height;
if position.y < track_top || position.y > track_bottom {
y_offset += track.height;
continue;
}
if track.midi.ins == 0 {
return None;
}
let local_y = (position.y - y_offset).max(0.0);
let layout = track.lane_layout();
let midi_top = track.lane_top(Kind::MIDI, 0);
let last_midi = track.midi.ins.saturating_sub(1);
let midi_bottom = track.lane_top(Kind::MIDI, last_midi)
+ layout.lane_height_for(Kind::MIDI, last_midi);
if local_y < midi_top || local_y > midi_bottom {
return None;
}
let lane = track
.lane_index_at_y(Kind::MIDI, local_y)
.min(track.midi.ins.saturating_sub(1));
return Some((track.name.clone(), lane));
}
None
}
fn clip_at_position(&self, position: Point) -> Option<(String, Kind, usize)> {
let pps = self.pixels_per_sample().max(1.0e-6);
let state = self.state.blocking_read();
let mut y_offset = 0.0f32;
for track in state
.tracks
.iter()
.filter(|track| track.name != METRONOME_TRACK_ID)
{
let track_top = y_offset;
let track_bottom = y_offset + track.height;
if position.y < track_top || position.y > track_bottom {
y_offset += track.height;
continue;
}
let local_y = (position.y - y_offset).max(0.0);
let local_x = position.x.max(0.0);
let layout = track.lane_layout();
let audio_clip_h = layout.lane_height_for(Kind::Audio, 0).max(12.0);
if track.audio.ins > 0 {
let audio_top = track.lane_top(Kind::Audio, 0);
let audio_bottom = audio_top + audio_clip_h;
if local_y >= audio_top && local_y <= audio_bottom {
let (take_idx, take_count) = Self::assign_take_lanes(
&track.audio.clips,
|_| 0,
|clip| clip.start,
|clip| clip.length,
|clip| clip.take_lane_override,
);
let overlap = track
.audio
.clips
.iter()
.enumerate()
.filter(|(_, clip)| {
let cx = clip.start as f32 * pps;
let cw = (clip.length as f32 * pps).max(MIN_CLIP_WIDTH_PX);
local_x >= cx && local_x <= cx + cw
})
.map(|(idx, _)| idx)
.collect::<Vec<_>>();
if overlap.is_empty() {
return None;
}
let max_takes = overlap
.iter()
.filter_map(|idx| take_count.get(*idx).copied())
.max()
.unwrap_or(1)
.max(1);
let rel_y = (local_y - audio_top).max(0.0);
let slot_h = (audio_clip_h / max_takes as f32).max(1.0);
let desired_take = (rel_y / slot_h).floor() as usize;
if let Some(idx) = overlap
.iter()
.find(|idx| take_idx.get(**idx).copied().unwrap_or(0) == desired_take)
.copied()
{
return Some((track.name.clone(), Kind::Audio, idx));
}
return overlap
.iter()
.find_map(|idx| take_idx.get(*idx).is_some().then_some(*idx))
.map(|idx| (track.name.clone(), Kind::Audio, idx));
}
}
if track.midi.ins > 0 {
let midi_lane = track
.lane_index_at_y(Kind::MIDI, local_y)
.min(track.midi.ins.saturating_sub(1));
let midi_top = track.lane_top(Kind::MIDI, midi_lane);
let midi_clip_h = layout.lane_height_for(Kind::MIDI, midi_lane).max(12.0);
let midi_bottom = midi_top + midi_clip_h;
if local_y >= midi_top && local_y <= midi_bottom {
let (take_idx, take_count) = Self::assign_take_lanes(
&track.midi.clips,
|clip| clip.input_channel.min(track.midi.ins.saturating_sub(1)),
|clip| clip.start,
|clip| clip.length,
|clip| clip.take_lane_override,
);
let overlap = track
.midi
.clips
.iter()
.enumerate()
.filter(|(_, clip)| {
let lane = clip.input_channel.min(track.midi.ins.saturating_sub(1));
if lane != midi_lane {
return false;
}
let cx = clip.start as f32 * pps;
let cw = (clip.length as f32 * pps).max(MIN_CLIP_WIDTH_PX);
local_x >= cx && local_x <= cx + cw
})
.map(|(idx, _)| idx)
.collect::<Vec<_>>();
if overlap.is_empty() {
return None;
}
let max_takes = overlap
.iter()
.filter_map(|idx| take_count.get(*idx).copied())
.max()
.unwrap_or(1)
.max(1);
let rel_y = (local_y - midi_top).max(0.0);
let slot_h = (midi_clip_h / max_takes as f32).max(1.0);
let desired_take = (rel_y / slot_h).floor() as usize;
if let Some(idx) = overlap
.iter()
.find(|idx| take_idx.get(**idx).copied().unwrap_or(0) == desired_take)
.copied()
{
return Some((track.name.clone(), Kind::MIDI, idx));
}
return overlap
.iter()
.find_map(|idx| take_idx.get(*idx).is_some().then_some(*idx))
.map(|idx| (track.name.clone(), Kind::MIDI, idx));
}
}
return None;
}
None
}
fn clip_resize_handle_at_position(
&self,
position: Point,
) -> Option<(String, usize, Kind, bool)> {
const HANDLE_WIDTH: f32 = 8.0;
let (track_name, kind, clip_index) = self.clip_at_position(position)?;
let pps = self.pixels_per_sample().max(1.0e-6);
let state = self.state.blocking_read();
let track = state.tracks.iter().find(|track| track.name == track_name)?;
let (start, length, locked) = match kind {
Kind::Audio => {
let clip = track.audio.clips.get(clip_index)?;
(clip.start, clip.length, clip.take_lane_locked)
}
Kind::MIDI => {
let clip = track.midi.clips.get(clip_index)?;
(clip.start, clip.length, clip.take_lane_locked)
}
};
if locked {
return None;
}
let left = start as f32 * pps;
let right = left + (length as f32 * pps).max(MIN_CLIP_WIDTH_PX);
let left_distance = (position.x - left).abs();
let right_distance = (position.x - right).abs();
let is_right_side = if left_distance <= HANDLE_WIDTH || right_distance <= HANDLE_WIDTH {
right_distance < left_distance
} else {
return None;
};
Some((track_name, clip_index, kind, is_right_side))
}
fn update_cut_indicator(&mut self, position: Point) {
let mut y_offset = 0.0f32;
let mut indicator = None;
{
let state = self.state.blocking_read();
for track in state.tracks.iter().filter(|t| t.name != METRONOME_TRACK_ID) {
let track_top = y_offset;
let track_bottom = y_offset + track.height;
if position.y < track_top || position.y > track_bottom {
y_offset += track.height;
continue;
}
indicator = Some((position.x, y_offset, track.height));
break;
}
}
let mut state = self.state.blocking_write();
state.cut_indicator = indicator;
}
fn selected_group_candidate(&self) -> Option<(String, Kind, Vec<usize>)> {
let state = self.state.blocking_read();
let mut selected: Vec<_> = state.selected_clips.iter().cloned().collect();
if selected.len() < 2 {
return None;
}
selected.sort_by_key(|clip| clip.clip_idx);
let first = selected.first()?.clone();
if selected
.iter()
.any(|clip| clip.track_idx != first.track_idx || clip.kind != first.kind)
{
return None;
}
let track = state
.tracks
.iter()
.find(|track| track.name == first.track_idx)?;
let valid = match first.kind {
Kind::Audio => selected.iter().all(|clip| {
track
.audio
.clips
.get(clip.clip_idx)
.is_some_and(|clip| !clip.is_group())
}),
Kind::MIDI => selected.iter().all(|clip| {
track
.midi
.clips
.get(clip.clip_idx)
.is_some_and(|clip| !clip.is_group())
}),
};
if !valid {
return None;
}
Some((
first.track_idx,
first.kind,
selected.into_iter().map(|clip| clip.clip_idx).collect(),
))
}
fn group_selected_clips(&mut self) -> Task<Message> {
let Some((track_name, kind, mut clip_indices)) = self.selected_group_candidate() else {
self.state.blocking_write().message =
"Select two or more clips of the same type on one track to group".to_string();
return Task::none();
};
clip_indices.sort_unstable();
let grouped_count = clip_indices.len();
let mut tasks = vec![self.send(Action::BeginHistoryGroup)];
match kind {
Kind::Audio => {
let Some(track) = self
.state
.blocking_read()
.tracks
.iter()
.find(|track| track.name == track_name)
.cloned()
else {
return Task::none();
};
let mut clips: Vec<_> = clip_indices
.iter()
.filter_map(|idx| track.audio.clips.get(*idx).cloned())
.collect();
if clips.len() < 2 {
return Task::none();
}
clips.sort_by_key(|clip| clip.start);
let group_start = clips.iter().map(|clip| clip.start).min().unwrap_or(0);
let group_end = clips
.iter()
.map(|clip| clip.start.saturating_add(clip.length))
.max()
.unwrap_or(group_start);
let mut grouped_clips = clips;
for child in &mut grouped_clips {
child.start = child.start.saturating_sub(group_start);
child.normalize_group_children();
}
tasks.push(self.send(Action::RemoveClip {
track_name: track_name.clone(),
kind,
clip_indices: clip_indices.clone(),
}));
tasks.push(
self.send(Action::AddGroupedClip {
track_name: track_name.clone(),
kind,
audio_clip: Some(Self::audio_clip_to_data(&crate::state::AudioClip {
id: crate::state::generate_clip_id(),
name: "Group".to_string(),
start: group_start,
length: group_end.saturating_sub(group_start).max(1),
offset: 0,
input_channel: grouped_clips
.first()
.map(|clip| clip.input_channel)
.unwrap_or(0),
muted: grouped_clips.iter().all(|clip| clip.muted),
max_length_samples: group_end.saturating_sub(group_start).max(1),
source_length_samples: 0,
peaks_file: None,
peaks: Default::default(),
fade_enabled: true,
fade_in_samples: 240,
fade_out_samples: 240,
pitch_correction_preview_name: None,
pitch_correction_source_name: None,
pitch_correction_source_offset: None,
pitch_correction_source_length: None,
pitch_correction_points: vec![],
pitch_correction_frame_likeness: None,
pitch_correction_inertia_ms: None,
pitch_correction_formant_compensation: None,
take_lane_override: None,
take_lane_pinned: false,
take_lane_locked: false,
plugin_graph_json: None,
grouped_clips,
})),
midi_clip: None,
}),
);
}
Kind::MIDI => {
let Some(track) = self
.state
.blocking_read()
.tracks
.iter()
.find(|track| track.name == track_name)
.cloned()
else {
return Task::none();
};
let mut clips: Vec<_> = clip_indices
.iter()
.filter_map(|idx| track.midi.clips.get(*idx).cloned())
.collect();
if clips.len() < 2 {
return Task::none();
}
clips.sort_by_key(|clip| clip.start);
let group_start = clips.iter().map(|clip| clip.start).min().unwrap_or(0);
let group_end = clips
.iter()
.map(|clip| clip.start.saturating_add(clip.length))
.max()
.unwrap_or(group_start);
let mut grouped_clips = clips;
for child in &mut grouped_clips {
child.start = child.start.saturating_sub(group_start);
child.normalize_group_children();
}
tasks.push(self.send(Action::RemoveClip {
track_name: track_name.clone(),
kind,
clip_indices: clip_indices.clone(),
}));
tasks.push(
self.send(Action::AddGroupedClip {
track_name: track_name.clone(),
kind,
audio_clip: None,
midi_clip: Some(Self::midi_clip_to_data(&crate::state::MIDIClip {
id: crate::state::generate_clip_id(),
name: "Group".to_string(),
start: group_start,
length: group_end.saturating_sub(group_start).max(1),
offset: 0,
input_channel: grouped_clips
.first()
.map(|clip| clip.input_channel)
.unwrap_or(0),
muted: grouped_clips.iter().all(|clip| clip.muted),
max_length_samples: group_end.saturating_sub(group_start).max(1),
take_lane_override: None,
take_lane_pinned: false,
take_lane_locked: false,
grouped_clips,
})),
}),
);
}
}
tasks.push(self.send(Action::EndHistoryGroup));
{
let mut state = self.state.blocking_write();
state.selected_clips.clear();
state.clip_context_menu = None;
state.message = format!("Grouped {} clips", grouped_count);
}
Task::batch(tasks)
}
fn ungroup_clip(&mut self, track_name: String, clip_idx: usize, kind: Kind) -> Task<Message> {
let mut tasks = vec![self.send(Action::BeginHistoryGroup)];
match kind {
Kind::Audio => {
let Some(group) = self
.state
.blocking_read()
.tracks
.iter()
.find(|track| track.name == track_name)
.and_then(|track| track.audio.clips.get(clip_idx))
.cloned()
else {
return Task::none();
};
if !group.is_group() {
return Task::none();
}
tasks.push(self.send(Action::RemoveClip {
track_name: track_name.clone(),
kind,
clip_indices: vec![clip_idx],
}));
for mut child in group.grouped_clips {
child.start = child.start.saturating_add(group.start);
if child.is_group() {
tasks.push(self.send(Action::AddGroupedClip {
track_name: track_name.clone(),
kind,
audio_clip: Some(Self::audio_clip_to_data(&child)),
midi_clip: None,
}));
} else {
tasks.push(
self.send(Action::AddClip {
clip_id: child.id,
name: child.name,
track_name: track_name.clone(),
start: child.start,
length: child.length,
offset: child.offset,
input_channel: child.input_channel,
muted: child.muted,
peaks_file: child.peaks_file,
kind,
fade_enabled: child.fade_enabled,
fade_in_samples: child.fade_in_samples,
fade_out_samples: child.fade_out_samples,
source_name: child.pitch_correction_source_name,
source_offset: child.pitch_correction_source_offset,
source_length: child.pitch_correction_source_length,
preview_name: child.pitch_correction_preview_name,
pitch_correction_points: child
.pitch_correction_points
.into_iter()
.map(|point| maolan_engine::message::PitchCorrectionPointData {
start_sample: point.start_sample,
length_samples: point.length_samples,
detected_midi_pitch: point.detected_midi_pitch,
target_midi_pitch: point.target_midi_pitch,
clarity: point.clarity,
})
.collect(),
pitch_correction_frame_likeness: child
.pitch_correction_frame_likeness,
pitch_correction_inertia_ms: child.pitch_correction_inertia_ms,
pitch_correction_formant_compensation: child
.pitch_correction_formant_compensation,
plugin_graph_json: child.plugin_graph_json,
}),
);
}
}
}
Kind::MIDI => {
let Some(group) = self
.state
.blocking_read()
.tracks
.iter()
.find(|track| track.name == track_name)
.and_then(|track| track.midi.clips.get(clip_idx))
.cloned()
else {
return Task::none();
};
if !group.is_group() {
return Task::none();
}
tasks.push(self.send(Action::RemoveClip {
track_name: track_name.clone(),
kind,
clip_indices: vec![clip_idx],
}));
for mut child in group.grouped_clips {
child.start = child.start.saturating_add(group.start);
if child.is_group() {
tasks.push(self.send(Action::AddGroupedClip {
track_name: track_name.clone(),
kind,
audio_clip: None,
midi_clip: Some(Self::midi_clip_to_data(&child)),
}));
} else {
tasks.push(self.send(Action::AddClip {
clip_id: child.id,
name: child.name,
track_name: track_name.clone(),
start: child.start,
length: child.length,
offset: child.offset,
input_channel: child.input_channel,
muted: child.muted,
peaks_file: None,
kind,
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,
}));
}
}
}
}
tasks.push(self.send(Action::EndHistoryGroup));
{
let mut state = self.state.blocking_write();
state.selected_clips.clear();
state.clip_context_menu = None;
state.message = "Ungrouped clip".to_string();
}
Task::batch(tasks)
}
fn assign_clip_to_session_slot(
&mut self,
track_name: String,
clip_idx: usize,
kind: Kind,
) -> Task<Message> {
let (clip_id, scene_index) = {
let state = self.state.blocking_read();
let Some(track) = state.tracks.iter().find(|t| t.name == track_name) else {
return Task::none();
};
let Some(clip_id) = (match kind {
Kind::Audio => track.audio.clips.get(clip_idx).map(|c| c.id.clone()),
Kind::MIDI => track.midi.clips.get(clip_idx).map(|c| c.id.clone()),
}) else {
return Task::none();
};
let Some(scene_index) = state
.selected_slots
.iter()
.find(|(name, _)| name == &track_name)
.map(|(_, scene_index)| *scene_index)
else {
return Task::none();
};
(clip_id, scene_index)
};
{
let mut state = self.state.blocking_write();
state.session.ensure_track_slots(&track_name);
let Some(slot) = state.session.slot_mut(&track_name, scene_index) else {
return Task::none();
};
slot.clip = Some(crate::state::SlotClipRef {
clip_id: clip_id.clone(),
launch_mode: crate::state::LaunchMode::Toggle,
launch_quantization: crate::state::LaunchQuantization::Bar,
loop_enabled: true,
loop_start_samples: 0,
loop_end_samples: 0,
});
state.message = "Assigned clip to session slot".to_string();
}
self.send(Action::TrackSetSessionSlot {
track_name,
scene_index,
clip_id: Some(clip_id),
})
}
fn split_clip_at_position(&mut self, position: Point) -> Task<Message> {
let mut result = self.clip_at_position(position);
if result.is_none() {
let pps = self.pixels_per_sample().max(1.0e-6);
let local_x = position.x.max(0.0);
let state = self.state.blocking_read();
let mut y_offset = 0.0f32;
for track in state.tracks.iter().filter(|t| t.name != METRONOME_TRACK_ID) {
let track_top = y_offset;
let track_bottom = y_offset + track.height;
if position.y < track_top || position.y > track_bottom {
y_offset += track.height;
continue;
}
if let Some((idx, _)) = track.audio.clips.iter().enumerate().find(|(_, clip)| {
let cx = clip.start as f32 * pps;
let cw = (clip.length as f32 * pps).max(MIN_CLIP_WIDTH_PX);
local_x >= cx && local_x <= cx + cw
}) {
result = Some((track.name.clone(), Kind::Audio, idx));
break;
}
if let Some((idx, _)) = track.midi.clips.iter().enumerate().find(|(_, clip)| {
let cx = clip.start as f32 * pps;
let cw = (clip.length as f32 * pps).max(MIN_CLIP_WIDTH_PX);
local_x >= cx && local_x <= cx + cw
}) {
result = Some((track.name.clone(), Kind::MIDI, idx));
break;
}
y_offset += track.height;
}
}
let Some((track_name, kind, clip_idx)) = result else {
return Task::none();
};
let pps = self.pixels_per_sample().max(1.0e-6);
let split_sample = self.snap_sample_to_bar(position.x.max(0.0) / pps);
match kind {
Kind::Audio => {
let Some(clip) = self
.state
.blocking_read()
.tracks
.iter()
.find(|t| t.name == track_name)
.and_then(|t| t.audio.clips.get(clip_idx))
.cloned()
else {
return Task::none();
};
let clip_end = clip.start.saturating_add(clip.length);
if clip.take_lane_locked {
self.state.blocking_write().message =
"Cannot split a take-lane locked clip".to_string();
return Task::none();
}
if clip.is_group() {
self.state.blocking_write().message = "Cannot split a grouped clip".to_string();
return Task::none();
}
if split_sample <= clip.start || split_sample >= clip_end {
return Task::none();
}
let left_len = split_sample.saturating_sub(clip.start);
let right_len = clip_end.saturating_sub(split_sample);
if left_len == 0 || right_len == 0 {
return Task::none();
}
let left_fade_in = clip.fade_in_samples.min(left_len / 2);
let left_fade_out = clip.fade_out_samples.min(left_len / 2);
let right_fade_in = clip.fade_in_samples.min(right_len / 2);
let right_fade_out = clip.fade_out_samples.min(right_len / 2);
let left_key = Self::audio_clip_key(
&track_name,
&clip.name,
clip.start,
left_len,
clip.offset,
);
let right_key = Self::audio_clip_key(
&track_name,
&clip.name,
split_sample,
right_len,
clip.offset.saturating_add(left_len),
);
if !clip.peaks.is_empty() {
self.pending_precomputed_peaks
.insert(left_key.clone(), clip.peaks.clone());
self.pending_precomputed_peaks
.insert(right_key.clone(), clip.peaks.clone());
}
self.pending_source_lengths.insert(left_key, 0usize);
self.pending_source_lengths.insert(right_key, 0usize);
self.state.blocking_write().message = format!("Split audio clip '{}'", clip.name);
self.send(Action::ApplyGroupedActions(vec![
Action::RemoveClip {
track_name: track_name.clone(),
kind: Kind::Audio,
clip_indices: vec![clip_idx],
},
Action::AddClip {
clip_id: crate::state::generate_clip_id(),
name: clip.name.clone(),
track_name: track_name.clone(),
start: clip.start,
length: left_len,
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: left_fade_in,
fade_out_samples: left_fade_out,
source_name: clip.pitch_correction_source_name.clone(),
source_offset: clip.pitch_correction_source_offset,
source_length: clip
.pitch_correction_source_length
.map(|value| value.min(left_len)),
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: clip.plugin_graph_json.clone(),
},
Action::AddClip {
clip_id: crate::state::generate_clip_id(),
name: clip.name,
track_name,
start: split_sample,
length: right_len,
offset: clip.offset.saturating_add(left_len),
input_channel: clip.input_channel,
muted: clip.muted,
peaks_file: clip.peaks_file,
kind: Kind::Audio,
fade_enabled: clip.fade_enabled,
fade_in_samples: right_fade_in,
fade_out_samples: right_fade_out,
source_name: clip.pitch_correction_source_name,
source_offset: clip
.pitch_correction_source_offset
.map(|value| value.saturating_add(left_len)),
source_length: clip
.pitch_correction_source_length
.map(|value| value.saturating_sub(left_len)),
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: clip.plugin_graph_json,
},
]))
}
Kind::MIDI => {
let Some(clip) = self
.state
.blocking_read()
.tracks
.iter()
.find(|t| t.name == track_name)
.and_then(|t| t.midi.clips.get(clip_idx))
.cloned()
else {
return Task::none();
};
let clip_end = clip.start.saturating_add(clip.length);
if clip.take_lane_locked {
self.state.blocking_write().message =
"Cannot split a take-lane locked clip".to_string();
return Task::none();
}
if clip.is_group() {
self.state.blocking_write().message = "Cannot split a grouped clip".to_string();
return Task::none();
}
if split_sample <= clip.start || split_sample >= clip_end {
return Task::none();
}
let left_len = split_sample.saturating_sub(clip.start);
let right_len = clip_end.saturating_sub(split_sample);
if left_len == 0 || right_len == 0 {
return Task::none();
}
let left_key = Self::audio_clip_key(
&track_name,
&clip.name,
clip.start,
left_len,
clip.offset,
);
let right_key = Self::audio_clip_key(
&track_name,
&clip.name,
split_sample,
right_len,
clip.offset.saturating_add(left_len),
);
self.pending_source_lengths.insert(left_key, 0usize);
self.pending_source_lengths.insert(right_key, 0usize);
self.state.blocking_write().message = format!("Split MIDI clip '{}'", clip.name);
self.send(Action::ApplyGroupedActions(vec![
Action::RemoveClip {
track_name: track_name.clone(),
kind: Kind::MIDI,
clip_indices: vec![clip_idx],
},
Action::AddClip {
clip_id: crate::state::generate_clip_id(),
name: clip.name.clone(),
track_name: track_name.clone(),
start: clip.start,
length: left_len,
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,
},
Action::AddClip {
clip_id: crate::state::generate_clip_id(),
name: clip.name,
track_name,
start: split_sample,
length: right_len,
offset: clip.offset.saturating_add(left_len),
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,
},
]))
}
}
}
fn create_empty_midi_clip_from_drag(&mut self, start: Point, end: Point) -> Task<Message> {
let Some((track_name, input_channel)) = self.midi_lane_at_position(start) else {
return Task::none();
};
let Some(session_root) = self.session_dir.clone() else {
self.state.blocking_write().message =
"Creating MIDI clips requires an opened/saved session".to_string();
return Task::none();
};
let pps = self.pixels_per_sample().max(1.0e-6);
let x0 = start.x.min(end.x).max(0.0);
let x1 = start.x.max(end.x).max(0.0);
let start_sample = self.snap_sample_to_bar(x0 / pps);
let mut end_sample = self.snap_sample_to_bar(x1 / pps);
let min_len = self.snap_interval_samples().max(1);
if end_sample <= start_sample {
end_sample = start_sample.saturating_add(min_len);
}
let length = end_sample.saturating_sub(start_sample).max(min_len);
let clip_name = match self.create_empty_midi_clip_file(&track_name, &session_root) {
Ok(name) => name,
Err(e) => {
self.state.blocking_write().message = format!("Failed to create MIDI clip: {e}");
return Task::none();
}
};
self.send(Action::AddClip {
clip_id: crate::state::generate_clip_id(),
name: clip_name,
track_name,
start: start_sample,
length,
offset: 0,
input_channel,
muted: false,
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,
})
}
fn open_clip_pitch_correction(&mut self, track_idx: String, clip_idx: usize) -> Task<Message> {
if self.playing {
self.state.blocking_write().message =
"Pitch correction is unavailable while playing or paused".to_string();
return Task::none();
}
let Some(session_root) = self.session_dir.clone() else {
self.state.blocking_write().message =
"Pitch correction requires an opened/saved session".to_string();
return Task::none();
};
let clip = {
let state = self.state.blocking_read();
state
.tracks
.iter()
.find(|t| t.name == track_idx)
.and_then(|t| t.audio.clips.get(clip_idx))
.cloned()
};
let Some(clip) = clip else {
self.state.blocking_write().message = "Audio clip not found".to_string();
return Task::none();
};
let request = {
let state = self.state.blocking_read();
ClipPitchCorrectionRequest {
track_idx: track_idx.clone(),
clip_idx,
clip_name: clip.name.clone(),
start: clip.start,
source_name: clip
.pitch_correction_source_name
.clone()
.unwrap_or_else(|| clip.name.clone()),
source_offset: clip.pitch_correction_source_offset.unwrap_or(clip.offset),
source_length: clip.pitch_correction_source_length.unwrap_or(clip.length),
frame_likeness: clip
.pitch_correction_frame_likeness
.unwrap_or(state.pitch_correction_frame_likeness),
}
};
if !clip.pitch_correction_points.is_empty() {
self.state.blocking_write().message =
format!("Opened pitch correction for '{}'", request.clip_name);
return Task::done(Message::ClipOpenPitchCorrectionFinished {
request: request.clone(),
result: Ok(crate::state::PitchCorrectionData {
track_idx: request.track_idx.clone(),
clip_index: request.clip_idx,
clip_name: request.clip_name.clone(),
clip_length_samples: request.source_length,
frame_likeness: request.frame_likeness,
raw_points: clip.pitch_correction_points.clone(),
points: clip.pitch_correction_points,
}),
});
}
let source_path = if std::path::Path::new(&request.source_name).is_absolute() {
std::path::PathBuf::from(&request.source_name)
} else {
session_root.join(&request.source_name)
};
if !source_path.exists() {
self.state.blocking_write().message =
format!("Audio clip source is missing: {}", source_path.display());
return Task::none();
}
if let Ok(Some(pitch_correction)) =
Self::load_cached_pitch_correction(&session_root, &source_path, &request)
{
self.state.blocking_write().message =
format!("Opened cached pitch correction for '{}'", request.clip_name);
return Task::done(Message::ClipOpenPitchCorrectionFinished {
request,
result: Ok(pitch_correction),
});
}
self.state.blocking_write().message =
format!("Opening pitch correction for '{}'...", request.clip_name);
self.clip_pitch_correction_in_progress = true;
self.clip_pitch_correction_progress = 0.0;
self.clip_pitch_correction_clip_name = request.clip_name.clone();
self.clip_pitch_correction_operation = Some("Starting".to_string());
let session_root_for_cache = session_root.clone();
Task::run(
{
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
tokio::spawn(async move {
let tx_clone = tx.clone();
let clip_name_for_progress = request.clip_name.clone();
let mut last_progress_bucket: Option<u16> = None;
let mut last_operation: Option<String> = None;
let progress_fn = move |progress: f32, operation: Option<String>| {
let clamped = progress.clamp(0.0, 1.0);
let bucket = (clamped * 100.0).round() as u16;
if last_progress_bucket == Some(bucket) && last_operation == operation {
return;
}
last_progress_bucket = Some(bucket);
last_operation = operation.clone();
if tx_clone
.send(Message::ClipOpenPitchCorrectionProgress {
clip_name: clip_name_for_progress.clone(),
progress: clamped,
operation,
})
.is_err()
{}
};
let result = Self::analyze_audio_clip_pitch_correction(
&source_path,
&request.clip_name,
request.source_offset,
request.source_length,
request.frame_likeness,
progress_fn,
)
.await
.map_err(|e| e.to_string());
if let Ok(ref pitch_correction) = result
&& let Err(_err) = Self::save_cached_pitch_correction(
&session_root_for_cache,
&source_path,
&request,
pitch_correction,
)
{}
if tx
.send(Message::ClipOpenPitchCorrectionFinished { request, result })
.is_err()
{
return;
}
drop(tx);
});
iced::futures::stream::unfold(rx, |mut rx| async move {
rx.recv().await.map(|msg| (msg, rx))
})
},
|msg| msg,
)
}
fn start_clip_stretch_request(&mut self, request: ClipStretchRequest) -> Task<Message> {
let Some(session_root) = self.session_dir.clone() else {
self.state.blocking_write().message =
"Stretching audio clips requires an opened/saved session".to_string();
return Task::none();
};
let source_path = if std::path::Path::new(&request.clip_name).is_absolute() {
std::path::PathBuf::from(&request.clip_name)
} else {
session_root.join(&request.clip_name)
};
self.start_clip_stretch_request_with_source(request, session_root, source_path)
}
fn start_clip_stretch_request_with_source(
&mut self,
request: ClipStretchRequest,
session_root: std::path::PathBuf,
source_path: std::path::PathBuf,
) -> Task<Message> {
if !source_path.exists() {
self.state.blocking_write().message =
format!("Audio clip source is missing: {}", source_path.display());
return Task::none();
}
Task::perform(
async move {
let result = Self::stretch_audio_clip_with_timestretch(
&source_path,
&session_root,
&request.clip_name,
request.offset,
request.length,
request.stretch_ratio,
)
.await
.map_err(|e| e.to_string());
(request, result)
},
|(request, result)| Message::ClipStretchFinished { request, result },
)
}
fn write_pitch_correction_midi_file(
path: &std::path::Path,
sample_rate: u32,
points: &[crate::state::PitchCorrectionPoint],
) -> Result<usize, String> {
let ppq = 480_u16;
let ticks_per_second = 960_u64;
let sample_rate = sample_rate.max(1) as u64;
let mut midi_events = Vec::new();
for point in points {
if point.length_samples == 0 || !point.target_midi_pitch.is_finite() {
continue;
}
let key = point.target_midi_pitch.round().clamp(0.0, 127.0) as u8;
let velocity = (point.clarity.clamp(0.0, 1.0) * 126.0).round() as u8 + 1;
midi_events.push((point.start_sample as u64, false, key, velocity));
midi_events.push((
point.start_sample.saturating_add(point.length_samples) as u64,
true,
key,
0,
));
}
midi_events.sort_by_key(|(sample, note_off, key, _)| (*sample, !*note_off, *key));
let mut track_events = vec![midly::TrackEvent {
delta: midly::num::u28::new(0),
kind: midly::TrackEventKind::Meta(midly::MetaMessage::Tempo(midly::num::u24::new(
500_000,
))),
}];
let mut prev_ticks = 0_u64;
let mut note_count = 0usize;
for (sample, note_off, key, velocity) in midi_events {
let ticks = sample.saturating_mul(ticks_per_second) / sample_rate;
let delta = ticks.saturating_sub(prev_ticks).min(u32::MAX as u64) as u32;
prev_ticks = ticks;
let message = if note_off {
midly::MidiMessage::NoteOff {
key: midly::num::u7::new(key),
vel: midly::num::u7::new(0),
}
} else {
note_count += 1;
midly::MidiMessage::NoteOn {
key: midly::num::u7::new(key),
vel: midly::num::u7::new(velocity),
}
};
track_events.push(midly::TrackEvent {
delta: midly::num::u28::new(delta),
kind: midly::TrackEventKind::Midi {
channel: midly::num::u4::new(0),
message,
},
});
}
track_events.push(midly::TrackEvent {
delta: midly::num::u28::new(0),
kind: midly::TrackEventKind::Meta(midly::MetaMessage::EndOfTrack),
});
let smf = midly::Smf {
header: midly::Header::new(
midly::Format::SingleTrack,
midly::Timing::Metrical(midly::num::u15::new(ppq)),
),
tracks: vec![track_events],
};
let mut file = fs::File::create(path).map_err(|e| e.to_string())?;
smf.write_std(&mut file).map_err(|e| e.to_string())?;
Ok(note_count)
}
fn current_pitch_correction_action(&self) -> Option<Action> {
let (
preview_name,
track_name,
clip_index,
source_name,
source_offset,
source_length,
points,
frame_likeness,
inertia_ms,
formant_compensation,
) = ({
let state = self.state.blocking_read();
let pitch_correction = state.pitch_correction.as_ref()?;
state
.tracks
.iter()
.find(|t| t.name == pitch_correction.track_idx)
.and_then(|t| t.audio.clips.get(pitch_correction.clip_index))
.cloned()
.map(|clip| {
(
clip.pitch_correction_preview_name.clone(),
pitch_correction.track_idx.clone(),
pitch_correction.clip_index,
clip.pitch_correction_source_name
.clone()
.unwrap_or_else(|| clip.name.clone()),
clip.pitch_correction_source_offset.unwrap_or(clip.offset),
clip.pitch_correction_source_length.unwrap_or(clip.length),
pitch_correction.points.clone(),
pitch_correction.frame_likeness,
state.pitch_correction_inertia_ms.min(1000),
state.pitch_correction_formant_compensation,
)
})
})?;
Some(Action::SetClipPitchCorrection {
track_name,
clip_index,
preview_name,
source_name: Some(source_name),
source_offset: Some(source_offset),
source_length: Some(source_length),
pitch_correction_points: points
.into_iter()
.map(|point| maolan_engine::message::PitchCorrectionPointData {
start_sample: point.start_sample,
length_samples: point.length_samples,
detected_midi_pitch: point.detected_midi_pitch,
target_midi_pitch: point.target_midi_pitch,
clarity: point.clarity,
})
.collect(),
pitch_correction_frame_likeness: Some(frame_likeness),
pitch_correction_inertia_ms: Some(inertia_ms),
pitch_correction_formant_compensation: Some(formant_compensation),
})
}
fn sync_pitch_correction_realtime(&mut self) -> Task<Message> {
let Some(action) = self.current_pitch_correction_action() else {
self.state.blocking_write().message = "Audio clip not found".to_string();
return Task::none();
};
self.send(action)
}
fn snap_pitch_correction_points_to_nearest(&mut self, point_index: usize) -> Task<Message> {
let mut state = self.state.blocking_write();
let selection = if state
.pitch_correction_selected_points
.contains(&point_index)
{
let mut indices: Vec<usize> = state
.pitch_correction_selected_points
.iter()
.copied()
.collect();
indices.sort_unstable();
indices
} else {
state.pitch_correction_selected_points.clear();
state.pitch_correction_selected_points.insert(point_index);
vec![point_index]
};
state.pitch_correction_dragging_points = None;
state.pitch_correction_selecting_rect = None;
let Some(pitch_correction) = state.pitch_correction.as_mut() else {
return Task::none();
};
let mut snapped = 0usize;
for idx in selection.iter().copied() {
if let Some(point) = pitch_correction.points.get_mut(idx) {
let snapped_pitch = point
.target_midi_pitch
.round()
.clamp(0.0, f32::from(PITCH_MAX));
if (point.target_midi_pitch - snapped_pitch).abs() > f32::EPSILON {
point.target_midi_pitch = snapped_pitch;
snapped = snapped.saturating_add(1);
}
}
}
state.message = if snapped == 0 {
if selection.len() == 1 {
"Pitch segment is already on the nearest note".to_string()
} else {
"Selected pitch segments are already on the nearest notes".to_string()
}
} else if selection.len() == 1 {
"Snapped pitch segment to nearest note".to_string()
} else {
format!("Snapped {} pitch segments to nearest notes", snapped)
};
drop(state);
if snapped > 0 {
return self.sync_pitch_correction_realtime();
}
Task::none()
}
fn schedule_audio_peak_rebuild(
&mut self,
track_name: &str,
clip_name: &str,
start: usize,
length: usize,
offset: usize,
wav_path: std::path::PathBuf,
) -> Option<Task<Message>> {
let key = Self::audio_clip_key(track_name, clip_name, start, length, offset);
if !self.pending_peak_rebuilds.insert(key) {
return None;
}
let track_name = track_name.to_string();
let clip_name = clip_name.to_string();
std::thread::spawn(move || {
if Self::stream_audio_clip_peaks_to_queue(
&wav_path,
track_name.clone(),
clip_name.clone(),
start,
length,
offset,
)
.is_err()
&& let Ok(mut queue) = AUDIO_PEAK_UPDATES.lock()
{
queue.push(super::AudioPeakChunkUpdate {
track_name,
clip_name,
start,
length,
offset,
channels: 0,
target_bins: 0,
bin_start: 0,
peaks: Vec::new(),
done: true,
});
}
});
Some(Task::none())
}
fn schedule_audio_peak_file_load(
&mut self,
track_name: &str,
clip_name: &str,
start: usize,
length: usize,
offset: usize,
peaks_path: std::path::PathBuf,
) -> Option<Task<Message>> {
let key = Self::audio_clip_key(track_name, clip_name, start, length, offset);
if !self.pending_peak_rebuilds.insert(key) {
return None;
}
let track_name = track_name.to_string();
let clip_name = clip_name.to_string();
std::thread::spawn(move || {
if Self::stream_peak_file_to_queue(
&peaks_path,
track_name.clone(),
clip_name.clone(),
start,
length,
offset,
)
.is_err()
&& let Ok(mut queue) = AUDIO_PEAK_UPDATES.lock()
{
queue.push(super::AudioPeakChunkUpdate {
track_name,
clip_name,
start,
length,
offset,
channels: 0,
target_bins: 0,
bin_start: 0,
peaks: Vec::new(),
done: true,
});
}
});
Some(Task::none())
}
}
#[cfg(all(test, unix, not(target_os = "macos")))]
mod tests {
use super::Maolan;
use crate::state::{AudioClip, PitchCorrectionData, PitchCorrectionPoint, Track};
use maolan_engine::kind::Kind;
use maolan_engine::message::Action;
#[test]
fn clip_plugin_graph_json_or_default_uses_track_io_counts() {
let graph = Maolan::clip_plugin_graph_json_or_default(None, 1, 1);
let connections = graph["connections"].as_array().expect("connections array");
assert_eq!(connections.len(), 1);
assert_eq!(connections[0]["from_node"], serde_json::json!("TrackInput"));
assert_eq!(connections[0]["to_node"], serde_json::json!("TrackOutput"));
assert_eq!(connections[0]["from_port"].as_u64(), Some(0));
assert_eq!(connections[0]["to_port"].as_u64(), Some(0));
}
#[test]
fn confirmed_clip_length_change_forks_copied_clip_identity_after_live_resize() {
let app = Maolan::default();
{
let mut state = app.state.blocking_write();
let mut track = Track::new("Synth".to_string(), 1.0, 2, 2, 0, 0);
track.audio.clips.push(AudioClip {
id: "shared-id".to_string(),
name: "audio/Synth.wav".to_string(),
start: 0,
length: 465_454,
..AudioClip::default()
});
track.audio.clips.push(AudioClip {
id: "shared-id".to_string(),
name: "audio/Synth.wav".to_string(),
start: 465_454,
length: 930_909,
..AudioClip::default()
});
state.tracks.push(track);
}
let action = app.action_with_confirmed_clip_length_change(Action::SetClipBounds {
track_name: "Synth".to_string(),
clip_index: 0,
kind: Kind::Audio,
start: 0,
length: 465_454,
offset: 0,
});
let Action::ApplyGroupedActions(actions) = action else {
panic!("length-changing copied clip should fork identity before bounds update");
};
assert_eq!(actions.len(), 2);
match &actions[0] {
Action::SetClipIdentity {
track_name,
clip_index,
kind,
new_id,
new_name,
} => {
assert_eq!(track_name, "Synth");
assert_eq!(*clip_index, 0);
assert_eq!(*kind, Kind::Audio);
assert_ne!(new_id, "shared-id");
assert_eq!(new_name, "audio/Synth_1.wav");
}
other => panic!("expected SetClipIdentity, got {other:?}"),
}
assert!(matches!(actions[1], Action::SetClipBounds { .. }));
}
#[test]
fn current_pitch_correction_action_uses_editor_state_immediately() {
let app = Maolan::default();
{
let mut state = app.state.blocking_write();
let mut track = Track::new("track".to_string(), 1.0, 1, 1, 0, 0);
track.audio.clips.push(AudioClip {
name: "audio/clip.wav".to_string(),
start: 12,
length: 4800,
offset: 24,
pitch_correction_source_name: Some("audio/source.wav".to_string()),
pitch_correction_source_offset: Some(48),
pitch_correction_source_length: Some(4096),
pitch_correction_preview_name: Some("audio/preview.wav".to_string()),
..AudioClip::default()
});
state.tracks.push(track);
state.pitch_correction = Some(PitchCorrectionData {
track_idx: "track".to_string(),
clip_index: 0,
clip_name: "audio/clip.wav".to_string(),
clip_length_samples: 4800,
frame_likeness: 0.75,
raw_points: vec![],
points: vec![PitchCorrectionPoint {
start_sample: 64,
length_samples: 128,
detected_midi_pitch: 60.25,
target_midi_pitch: 61.5,
clarity: 0.9,
}],
});
state.pitch_correction_inertia_ms = 333;
state.pitch_correction_formant_compensation = false;
}
let action = app
.current_pitch_correction_action()
.expect("pitch correction action");
match action {
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, "track");
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(48));
assert_eq!(source_length, Some(4096));
assert_eq!(pitch_correction_points.len(), 1);
assert_eq!(pitch_correction_points[0].target_midi_pitch, 61.5);
assert_eq!(pitch_correction_frame_likeness, Some(0.75));
assert_eq!(pitch_correction_inertia_ms, Some(333));
assert_eq!(pitch_correction_formant_compensation, Some(false));
}
other => panic!("unexpected action: {other:?}"),
}
}
#[test]
fn normalize_period_frames_power_of_two() {
assert_eq!(Maolan::normalize_period_frames(64), 64);
assert_eq!(Maolan::normalize_period_frames(512), 512);
assert_eq!(Maolan::normalize_period_frames(4096), 4096);
}
#[test]
fn normalize_period_frames_non_power_of_two() {
assert_eq!(Maolan::normalize_period_frames(100), 128);
assert_eq!(Maolan::normalize_period_frames(500), 512);
assert_eq!(Maolan::normalize_period_frames(3000), 4096);
}
#[test]
fn normalize_period_frames_clamps_to_bounds() {
assert_eq!(Maolan::normalize_period_frames(0), 1);
assert_eq!(Maolan::normalize_period_frames(5), 8);
assert_eq!(Maolan::normalize_period_frames(100000), 65536);
}
#[test]
fn quantize_meter_db_clamps_and_steps() {
assert_eq!(Maolan::quantize_meter_db(-100.0), -90.0);
assert_eq!(Maolan::quantize_meter_db(25.0), 20.0);
assert_eq!(Maolan::quantize_meter_db(5.3), 5.0);
assert_eq!(Maolan::quantize_meter_db(5.7), 6.0);
}
#[test]
fn deterministic_note_jitter_is_deterministic() {
let j1 = Maolan::deterministic_note_jitter(100, 200, 10);
let j2 = Maolan::deterministic_note_jitter(100, 200, 10);
assert_eq!(j1, j2);
}
#[test]
fn deterministic_note_jitter_respects_amplitude() {
let j1 = Maolan::deterministic_note_jitter(100, 200, 10);
assert!((-10..=10).contains(&j1));
}
#[test]
fn nearest_scale_pitch_major() {
assert_eq!(Maolan::nearest_scale_pitch(0, 0, false), 0);
assert_eq!(Maolan::nearest_scale_pitch(1, 0, false), 0);
assert_eq!(Maolan::nearest_scale_pitch(6, 0, false), 5);
}
#[test]
fn nearest_scale_pitch_minor() {
assert_eq!(Maolan::nearest_scale_pitch(0, 0, true), 0);
assert_eq!(Maolan::nearest_scale_pitch(1, 0, true), 0);
assert_eq!(Maolan::nearest_scale_pitch(4, 0, true), 3);
}
#[test]
fn format_sysex_hex_formats_bytes() {
assert_eq!(Maolan::format_sysex_hex(&[0xF0, 0x01, 0x02]), "F0 01 02");
assert_eq!(Maolan::format_sysex_hex(&[]), "");
}
#[test]
fn parse_sysex_hex_parses_valid_hex() {
assert_eq!(
Maolan::parse_sysex_hex("01 02").unwrap(),
vec![0xF0, 0x01, 0x02, 0xF7]
);
assert_eq!(
Maolan::parse_sysex_hex("f0,01,02").unwrap(),
vec![0xF0, 0x01, 0x02, 0xF7]
);
}
#[test]
fn parse_sysex_hex_rejects_invalid() {
assert!(Maolan::parse_sysex_hex("GG HH").is_err());
assert!(Maolan::parse_sysex_hex("").is_err());
}
#[test]
fn timing_at_sample_basic() {
let state = crate::state::StateData {
tempo: 120.0,
time_signature_num: 4,
time_signature_denom: 4,
hw_sample_rate_hz: 48000,
..Default::default()
};
let (bpm, num, denom) = Maolan::timing_at_sample(&state, 0);
assert_eq!(bpm, 120.0);
assert_eq!(num, 4);
assert_eq!(denom, 4);
}
#[test]
fn automation_key_equality() {
use crate::message::TrackAutomationTarget;
let key1 = Maolan::automation_key(TrackAutomationTarget::Volume);
let key2 = Maolan::automation_key(TrackAutomationTarget::Volume);
let key3 = Maolan::automation_key(TrackAutomationTarget::Balance);
let key4 = Maolan::automation_key(TrackAutomationTarget::MidiCc { channel: 0, cc: 7 });
assert_eq!(key1, key2);
assert_ne!(key1, key3);
assert_ne!(key1, key4);
}
#[test]
fn key_has_explicit_gesture_lifecycle() {
use super::AutomationWriteKey;
assert!(!Maolan::key_has_explicit_gesture_lifecycle(
AutomationWriteKey::Volume
));
assert!(Maolan::key_has_explicit_gesture_lifecycle(
AutomationWriteKey::Clap {
instance_id: 0,
param_id: 0
}
));
}
#[test]
fn complete_pending_save_writes_track_template() {
use std::{
fs,
sync::LazyLock,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
static GUARD: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
let _guard = GUARD.lock().expect("lock guard");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temp_home = std::env::temp_dir().join(format!("maolan_complete_pending_save_{unique}"));
let template_path = temp_home.join(".config/maolan/track_templates/Drums");
let old_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", &temp_home);
}
let mut app = Maolan::default();
{
let mut state = app.state.blocking_write();
state.tracks.push(crate::state::Track::new(
"Drums".to_string(),
0.0,
2,
2,
0,
0,
));
state
.plugin_graphs_by_track
.insert("Drums".to_string(), (vec![], vec![]));
}
app.pending_save_path = Some(template_path.to_string_lossy().to_string());
app.pending_save_tracks.clear();
app.pending_save_clap_tracks.clear();
app.pending_save_clap_clips.clear();
app.pending_save_is_template = false;
let _task = app.complete_pending_save("Drums");
if let Some(home) = old_home {
unsafe {
std::env::set_var("HOME", home);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
assert!(
template_path.join("track.json").exists(),
"track template save should write track.json"
);
let _ = fs::remove_dir_all(&temp_home);
}
#[test]
fn save_folder_track_template_writes_children() {
use std::{
fs,
sync::LazyLock,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
static GUARD: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
let _guard = GUARD.lock().expect("lock guard");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temp_home = std::env::temp_dir().join(format!("maolan_save_folder_template_{unique}"));
let template_path = temp_home.join(".config/maolan/track_templates/Drums");
let old_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", &temp_home);
}
let app = Maolan::default();
{
let mut state = app.state.blocking_write();
let mut folder = crate::state::Track::new("Drums".to_string(), 0.0, 2, 2, 0, 0);
folder.is_folder = true;
state.tracks.push(folder);
let mut kick = crate::state::Track::new("Kick".to_string(), 0.0, 1, 1, 0, 0);
kick.parent_track = Some("Drums".to_string());
state.tracks.push(kick);
let mut snare = crate::state::Track::new("Snare".to_string(), 0.0, 1, 1, 0, 0);
snare.parent_track = Some("Drums".to_string());
state.tracks.push(snare);
state
.plugin_graphs_by_track
.insert("Drums".to_string(), (vec![], vec![]));
state
.plugin_graphs_by_track
.insert("Kick".to_string(), (vec![], vec![]));
state
.plugin_graphs_by_track
.insert("Snare".to_string(), (vec![], vec![]));
}
let _task =
app.save_track_as_template("Drums", template_path.to_string_lossy().to_string());
if let Some(home) = old_home {
unsafe {
std::env::set_var("HOME", home);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
let track_json_path = template_path.join("track.json");
assert!(
track_json_path.exists(),
"folder template save should write track.json"
);
let saved: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
fs::File::open(&track_json_path).unwrap(),
))
.unwrap();
assert_eq!(
saved["track"]["is_folder"].as_bool(),
Some(true),
"root track should be marked as folder"
);
let children = saved["children"]
.as_array()
.expect("children array expected");
assert_eq!(children.len(), 2);
let child_names: std::collections::HashSet<String> = children
.iter()
.filter_map(|c| c["track"]["name"].as_str().map(String::from))
.collect();
assert!(child_names.contains("Kick"));
assert!(child_names.contains("Snare"));
let _ = fs::remove_dir_all(&temp_home);
}
#[test]
fn save_nested_folder_track_template_writes_children_recursively() {
use std::{
fs,
sync::LazyLock,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
static GUARD: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
let _guard = GUARD.lock().expect("lock guard");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temp_home =
std::env::temp_dir().join(format!("maolan_save_nested_folder_template_{unique}"));
let template_path = temp_home.join(".config/maolan/track_templates/Drums");
let old_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", &temp_home);
}
let app = Maolan::default();
{
let mut state = app.state.blocking_write();
let mut drums = crate::state::Track::new("Drums".to_string(), 0.0, 2, 2, 0, 0);
drums.is_folder = true;
state.tracks.push(drums);
let mut perc = crate::state::Track::new("Perc".to_string(), 0.0, 2, 2, 0, 0);
perc.is_folder = true;
perc.parent_track = Some("Drums".to_string());
state.tracks.push(perc);
let mut kick = crate::state::Track::new("Kick".to_string(), 0.0, 1, 1, 0, 0);
kick.parent_track = Some("Perc".to_string());
state.tracks.push(kick);
state
.plugin_graphs_by_track
.insert("Drums".to_string(), (vec![], vec![]));
state
.plugin_graphs_by_track
.insert("Perc".to_string(), (vec![], vec![]));
state
.plugin_graphs_by_track
.insert("Kick".to_string(), (vec![], vec![]));
}
let _task =
app.save_track_as_template("Drums", template_path.to_string_lossy().to_string());
if let Some(home) = old_home {
unsafe {
std::env::set_var("HOME", home);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
let track_json_path = template_path.join("track.json");
assert!(
track_json_path.exists(),
"nested folder template save should write track.json"
);
let saved: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
fs::File::open(&track_json_path).unwrap(),
))
.unwrap();
assert_eq!(
saved["track"]["is_folder"].as_bool(),
Some(true),
"root track should be marked as folder"
);
let children = saved["children"]
.as_array()
.expect("children array expected");
assert_eq!(children.len(), 1);
let perc = &children[0];
assert_eq!(
perc["track"]["name"].as_str(),
Some("Perc"),
"nested folder child should be saved"
);
assert_eq!(
perc["track"]["is_folder"].as_bool(),
Some(true),
"nested folder child should keep is_folder flag"
);
let nested_children = perc["children"]
.as_array()
.expect("nested children array expected");
assert_eq!(nested_children.len(), 1);
assert_eq!(
nested_children[0]["track"]["name"].as_str(),
Some("Kick"),
"deeply nested track should be saved"
);
let _ = fs::remove_dir_all(&temp_home);
}
#[test]
fn complete_pending_save_uses_original_folder_track_name() {
use std::{
fs,
sync::LazyLock,
sync::Mutex,
time::{SystemTime, UNIX_EPOCH},
};
static GUARD: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
let _guard = GUARD.lock().expect("lock guard");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temp_home =
std::env::temp_dir().join(format!("maolan_complete_pending_save_folder_{unique}"));
let template_path = temp_home.join(".config/maolan/track_templates/Drums");
let old_home = std::env::var("HOME").ok();
unsafe {
std::env::set_var("HOME", &temp_home);
}
let mut app = Maolan::default();
{
let mut state = app.state.blocking_write();
let mut folder = crate::state::Track::new("Drums".to_string(), 0.0, 2, 2, 0, 0);
folder.is_folder = true;
state.tracks.push(folder);
let mut kick = crate::state::Track::new("Kick".to_string(), 0.0, 1, 1, 0, 0);
kick.parent_track = Some("Drums".to_string());
state.tracks.push(kick);
state
.plugin_graphs_by_track
.insert("Drums".to_string(), (vec![], vec![]));
state
.plugin_graphs_by_track
.insert("Kick".to_string(), (vec![], vec![]));
}
app.pending_save_path = Some(template_path.to_string_lossy().to_string());
app.pending_save_track_name = Some("Drums".to_string());
app.pending_save_tracks.clear();
app.pending_save_clap_tracks.clear();
app.pending_save_clap_clips.clear();
app.pending_save_is_template = false;
let _task = app.complete_pending_save("Kick");
if let Some(home) = old_home {
unsafe {
std::env::set_var("HOME", home);
}
} else {
unsafe {
std::env::remove_var("HOME");
}
}
let track_json_path = template_path.join("track.json");
assert!(
track_json_path.exists(),
"folder template save should write track.json"
);
let saved: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
fs::File::open(&track_json_path).unwrap(),
))
.unwrap();
assert_eq!(
saved["track"]["name"].as_str(),
Some("Drums"),
"saved template should be the original folder track, not the last child response"
);
assert_eq!(
saved["track"]["is_folder"].as_bool(),
Some(true),
"saved folder template should keep is_folder flag"
);
let _ = fs::remove_dir_all(&temp_home);
}
fn test_audio_clip(start: usize, length: usize) -> crate::state::AudioClip {
crate::state::AudioClip {
id: String::new(),
name: "clip.wav".to_string(),
start,
length,
offset: 0,
input_channel: 0,
muted: false,
max_length_samples: length,
source_length_samples: length,
peaks_file: None,
peaks: std::sync::Arc::new(vec![]),
fade_enabled: true,
fade_in_samples: 240,
fade_out_samples: 240,
pitch_correction_preview_name: None,
pitch_correction_source_name: None,
pitch_correction_source_offset: None,
pitch_correction_source_length: None,
pitch_correction_points: vec![],
pitch_correction_frame_likeness: None,
pitch_correction_inertia_ms: None,
pitch_correction_formant_compensation: None,
take_lane_override: None,
take_lane_pinned: false,
take_lane_locked: false,
plugin_graph_json: None,
grouped_clips: vec![],
}
}
#[test]
fn cross_section_fades_match_non_aligned_overlap_length() {
let mut existing = vec![test_audio_clip(0, 100)];
let mut inserted = test_audio_clip(80, 100);
Maolan::apply_audio_cross_section_fades(&mut existing, &mut inserted);
assert_eq!(existing[0].fade_out_samples, 20);
assert_eq!(inserted.fade_in_samples, 20);
}
#[test]
fn cross_section_fades_ignore_aligned_starts_and_ends() {
let mut same_start = vec![test_audio_clip(0, 100)];
let mut inserted_same_start = test_audio_clip(0, 40);
Maolan::apply_audio_cross_section_fades(&mut same_start, &mut inserted_same_start);
assert_eq!(same_start[0].fade_in_samples, 240);
assert_eq!(inserted_same_start.fade_in_samples, 240);
let mut same_end = vec![test_audio_clip(0, 100)];
let mut inserted_same_end = test_audio_clip(60, 40);
Maolan::apply_audio_cross_section_fades(&mut same_end, &mut inserted_same_end);
assert_eq!(same_end[0].fade_out_samples, 240);
assert_eq!(inserted_same_end.fade_out_samples, 240);
}
#[test]
fn pitch_correction_midi_export_writes_note_events() {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let path = std::env::temp_dir().join(format!("maolan_pitch_export_{unique}.mid"));
let points = vec![PitchCorrectionPoint {
start_sample: 0,
length_samples: 48_000,
detected_midi_pitch: 60.2,
target_midi_pitch: 64.0,
clarity: 0.75,
}];
let written = Maolan::write_pitch_correction_midi_file(&path, 48_000, &points).unwrap();
assert_eq!(written, 1);
let bytes = std::fs::read(&path).unwrap();
let smf = midly::Smf::parse(&bytes).unwrap();
let midi_events = smf.tracks[0]
.iter()
.filter_map(|event| match event.kind {
midly::TrackEventKind::Midi { message, .. } => Some(message),
_ => None,
})
.collect::<Vec<_>>();
assert!(matches!(
midi_events.as_slice(),
[
midly::MidiMessage::NoteOn { key, .. },
midly::MidiMessage::NoteOff { key: off_key, .. }
] if key.as_int() == 64 && off_key.as_int() == 64
));
let _ = std::fs::remove_file(path);
}
}