use super::{CLIENT, Maolan};
use crate::{
message::Message,
state::{Connection, ConnectionViewSelection},
};
use iced::{Length, Point, Task};
use maolan_engine::{
kind::Kind,
message::{
Action, ConnectableConnection, ConnectableRef, GlobalMidiLearnTarget,
Message as EngineMessage,
},
};
use serde_json::{Value, json};
use std::{
fs::{self, File},
io::{self, BufReader, BufWriter},
path::{Path, PathBuf},
time::Instant,
};
impl Maolan {
fn clip_clap_snapshot_targets(&self, track_names: &[String]) -> Vec<(String, usize, usize)> {
let state = self.state.blocking_read();
track_names
.iter()
.filter_map(|track_name| {
state
.tracks
.iter()
.find(|track| track.name == *track_name)
.map(|track| (track_name, track))
})
.flat_map(|(track_name, track)| {
track
.audio
.clips
.iter()
.enumerate()
.flat_map(move |(clip_idx, clip)| {
clip.plugin_graph_json
.as_ref()
.and_then(|graph| graph.get("plugins"))
.and_then(Value::as_array)
.into_iter()
.flat_map(move |plugins| {
plugins.iter().enumerate().filter_map(move |(idx, plugin)| {
let is_clap = plugin
.get("format")
.and_then(Value::as_str)
.is_some_and(|format| format.eq_ignore_ascii_case("CLAP"));
let has_uri =
plugin.get("uri").and_then(Value::as_str).is_some();
if !is_clap || !has_uri {
return None;
}
let instance_id = plugins
.iter()
.take(idx)
.filter(|p| p.get("uri").and_then(Value::as_str).is_some())
.count();
Some((track_name.clone(), clip_idx, instance_id))
})
})
})
})
.collect()
}
fn export_render_mode_to_json(mode: crate::message::ExportRenderMode) -> Value {
Value::String(
match mode {
crate::message::ExportRenderMode::Mixdown => "mixdown",
crate::message::ExportRenderMode::StemsPostFader => "stems_post_fader",
crate::message::ExportRenderMode::StemsPreFader => "stems_pre_fader",
}
.to_string(),
)
}
fn export_render_mode_from_json(value: Option<&Value>) -> crate::message::ExportRenderMode {
match value.and_then(Value::as_str) {
Some("stems_post_fader") => crate::message::ExportRenderMode::StemsPostFader,
Some("stems_pre_fader") => crate::message::ExportRenderMode::StemsPreFader,
_ => crate::message::ExportRenderMode::Mixdown,
}
}
fn export_bit_depth_to_json(depth: crate::message::ExportBitDepth) -> Value {
Value::String(
match depth {
crate::message::ExportBitDepth::Int16 => "int16",
crate::message::ExportBitDepth::Int24 => "int24",
crate::message::ExportBitDepth::Int32 => "int32",
crate::message::ExportBitDepth::Float32 => "float32",
}
.to_string(),
)
}
fn export_bit_depth_from_json(value: Option<&Value>) -> crate::message::ExportBitDepth {
match value.and_then(Value::as_str) {
Some("int16") => crate::message::ExportBitDepth::Int16,
Some("int32") => crate::message::ExportBitDepth::Int32,
Some("float32") => crate::message::ExportBitDepth::Float32,
_ => crate::message::ExportBitDepth::Int24,
}
}
fn export_mp3_mode_to_json(mode: crate::message::ExportMp3Mode) -> Value {
Value::String(
match mode {
crate::message::ExportMp3Mode::Cbr => "cbr",
crate::message::ExportMp3Mode::Vbr => "vbr",
}
.to_string(),
)
}
fn export_mp3_mode_from_json(value: Option<&Value>) -> crate::message::ExportMp3Mode {
match value.and_then(Value::as_str) {
Some("vbr") => crate::message::ExportMp3Mode::Vbr,
_ => crate::message::ExportMp3Mode::Cbr,
}
}
fn export_normalize_mode_to_json(mode: crate::message::ExportNormalizeMode) -> Value {
Value::String(
match mode {
crate::message::ExportNormalizeMode::Peak => "peak",
crate::message::ExportNormalizeMode::Loudness => "loudness",
}
.to_string(),
)
}
fn export_normalize_mode_from_json(
value: Option<&Value>,
) -> crate::message::ExportNormalizeMode {
match value.and_then(Value::as_str) {
Some("loudness") => crate::message::ExportNormalizeMode::Loudness,
_ => crate::message::ExportNormalizeMode::Peak,
}
}
pub(super) fn restore_actions_task(actions: Vec<Action>) -> Task<Message> {
Task::perform(
async move {
for action in actions {
CLIENT
.send(EngineMessage::Request(action))
.await
.map_err(|e| e.to_string())?;
}
Ok(())
},
Message::SendMessageFinished,
)
}
fn plugin_save_uri(p: &maolan_engine::message::PluginGraphPlugin) -> String {
match p.format.as_str() {
"CLAP" | "VST3" => {
if p.plugin_id.is_empty() {
p.uri.clone()
} else {
p.plugin_id.clone()
}
}
_ => p.uri.clone(),
}
}
fn visible_controllers_to_json(
visible_controllers: &std::collections::HashMap<
String,
std::collections::HashMap<usize, Vec<crate::state::ShownPluginController>>,
>,
track_name: &str,
id_to_index: &std::collections::HashMap<usize, usize>,
) -> Value {
let mut result = serde_json::Map::new();
if let Some(controllers) = visible_controllers.get(track_name) {
for (instance_id, controllers) in controllers {
if let Some(&idx) = id_to_index.get(instance_id) {
let arr: Vec<Value> = controllers
.iter()
.map(|c| {
json!({
"param_id": c.param_id,
"name": c.name,
"value": c.value,
"min": c.min,
"max": c.max,
})
})
.collect();
if !arr.is_empty() {
result.insert(idx.to_string(), Value::Array(arr));
}
}
}
}
Value::Object(result)
}
fn visible_controllers_from_json(
value: &Value,
index_to_id: &std::collections::HashMap<usize, usize>,
) -> std::collections::HashMap<usize, Vec<crate::state::ShownPluginController>> {
let mut result = std::collections::HashMap::new();
if let Some(obj) = value.as_object() {
for (idx_str, controllers_v) in obj {
if let (Some(idx), Some(controllers_arr)) =
(idx_str.parse::<usize>().ok(), controllers_v.as_array())
&& let Some(&instance_id) = index_to_id.get(&idx)
{
let controllers: Vec<crate::state::ShownPluginController> = controllers_arr
.iter()
.filter_map(|c| {
Some(crate::state::ShownPluginController {
param_id: c.get("param_id").and_then(Value::as_u64)? as u32,
name: c.get("name").and_then(Value::as_str)?.to_string(),
value: c.get("value").and_then(Value::as_f64)? as f32,
min: c.get("min").and_then(Value::as_f64)? as f32,
max: c.get("max").and_then(Value::as_f64)? as f32,
})
})
.collect();
if !controllers.is_empty() {
result.insert(instance_id, controllers);
}
}
}
}
result
}
fn disabled_default_connections_json(
folder: &crate::state::Track,
tracks: &[crate::state::Track],
actual: &[ConnectableConnection],
) -> Vec<Value> {
let mut disabled = Vec::new();
for child in tracks
.iter()
.filter(|t| t.parent_track.as_deref() == Some(folder.name.as_str()))
{
let audio_out_count = folder.primary_audio_outs.min(child.primary_audio_outs);
for port in 0..audio_out_count {
let expected = ConnectableConnection {
from: ConnectableRef::ChildTrack(child.name.clone()),
from_port: port,
to: ConnectableRef::TrackOutput,
to_port: port,
kind: Kind::Audio,
};
if !actual.contains(&expected) {
disabled.push(json!({
"child": child.name.clone(),
"from_port": port,
"to_port": port,
"kind": Self::kind_to_json(Kind::Audio),
}));
}
}
let midi_out_count = folder.midi.outs.min(child.midi.outs);
for port in 0..midi_out_count {
let folder_port = folder.primary_audio_outs + port;
let expected = ConnectableConnection {
from: ConnectableRef::ChildTrack(child.name.clone()),
from_port: port,
to: ConnectableRef::TrackOutput,
to_port: folder_port,
kind: Kind::MIDI,
};
if !actual.contains(&expected) {
disabled.push(json!({
"child": child.name.clone(),
"from_port": port,
"to_port": folder_port,
"kind": Self::kind_to_json(Kind::MIDI),
}));
}
}
}
disabled
}
fn apply_disabled_default_connections(
track_name: &str,
graph_v: &Value,
restore_actions: &mut Vec<Action>,
) {
let Some(disabled_arr) = graph_v["disabled_default_connections"].as_array() else {
return;
};
for entry in disabled_arr {
let Some(child) = entry["child"].as_str() else {
continue;
};
let from_port = entry["from_port"].as_u64().unwrap_or(0) as usize;
let to_port = entry["to_port"].as_u64().unwrap_or(0) as usize;
let Some(kind) = Self::kind_from_json(&entry["kind"]) else {
continue;
};
match kind {
Kind::Audio => restore_actions.push(Action::TrackDisconnectAudio {
track_name: track_name.to_string(),
from: ConnectableRef::ChildTrack(child.to_string()),
from_port,
to: ConnectableRef::TrackOutput,
to_port,
}),
Kind::MIDI => restore_actions.push(Action::TrackDisconnectMidi {
track_name: track_name.to_string(),
from: ConnectableRef::ChildTrack(child.to_string()),
from_port,
to: ConnectableRef::TrackOutput,
to_port,
}),
}
}
}
fn resolve_clap_plugin_path(
stored: &str,
clap_plugins: &[maolan_engine::clap::ClapPluginInfo],
) -> Option<String> {
if stored.contains("::") || stored.contains('/') {
return Some(stored.to_string());
}
for info in clap_plugins {
if let Some((_, id)) = info.path.split_once("::")
&& id == stored
{
return Some(info.path.clone());
}
}
None
}
fn resolve_vst3_plugin_path(
stored: &str,
vst3_plugins: &[maolan_engine::vst3::Vst3PluginInfo],
) -> Option<String> {
if stored.contains('/') {
return Some(stored.to_string());
}
vst3_plugins
.iter()
.find(|info| info.path == stored || info.id == stored)
.map(|info| info.path.clone())
}
pub(super) fn save_template(&self, path: String) -> std::io::Result<()> {
let filename = "main.json";
let template_root = PathBuf::from(path.clone());
fs::create_dir_all(&path)?;
fs::create_dir_all(template_root.join("plugins"))?;
fs::create_dir_all(template_root.join("audio"))?;
fs::create_dir_all(template_root.join("midi"))?;
fs::create_dir_all(template_root.join("peaks"))?;
let mut p = template_root.clone();
p.push(filename);
let file = File::create(&p)?;
let state = self.state.blocking_read();
let tracks_width = match state.tracks_width {
Length::Fixed(v) => v,
_ => 200.0,
};
let live_view_tracks_width = match state.live_view_tracks_width {
Length::Fixed(v) => v,
_ => 200.0,
};
let live_view_left_split = state.live_view_left_split;
let mixer_height = match state.mixer_height {
Length::Fixed(v) => v,
_ => 300.0,
};
let mut tracks_json = serde_json::to_value(&state.tracks).map_err(io::Error::other)?;
if let Some(tracks) = tracks_json.as_array_mut() {
for track in tracks.iter_mut() {
if let Some(audio) = track.get_mut("audio").and_then(Value::as_object_mut) {
audio.insert("clips".to_string(), Value::Array(vec![]));
}
if let Some(midi) = track.get_mut("midi").and_then(Value::as_object_mut) {
midi.insert("clips".to_string(), Value::Array(vec![]));
}
if let Some(obj) = track.as_object_mut() {
obj.insert("frozen".to_string(), Value::Bool(false));
obj.insert("frozen_audio_backup".to_string(), Value::Array(vec![]));
obj.insert("frozen_midi_backup".to_string(), Value::Array(vec![]));
obj.insert("frozen_render_clip".to_string(), Value::Null);
}
}
}
let graphs = {
let mut graphs = serde_json::Map::new();
for (track_name, (plugins, connections)) in &state.plugin_graphs_by_track {
let id_to_index: std::collections::HashMap<usize, usize> = plugins
.iter()
.enumerate()
.map(|(idx, p)| (p.instance_id, idx))
.collect();
let plugins_json: Vec<Value> = plugins
.iter()
.map(|p| {
let state_json = p.state.clone().unwrap_or(Value::Null);
let has_state = !state_json.is_null();
tracing::info!(%track_name, instance_id = p.instance_id, format = %p.format, has_state, "save: writing plugin state");
json!({"format": p.format, "uri": Self::plugin_save_uri(p), "state": state_json})
})
.collect();
let conns_json: Vec<Value> = connections
.iter()
.filter_map(|c| {
let from_node = Self::plugin_node_to_json(&c.from_node, &id_to_index)?;
let to_node = Self::plugin_node_to_json(&c.to_node, &id_to_index)?;
Some(json!({
"from_node": from_node,
"from_port": c.from_port,
"to_node": to_node,
"to_port": c.to_port,
"kind": Self::kind_to_json(c.kind),
}))
})
.collect();
let connectable_conns_json: Vec<Value> = state
.connectable_connections_by_track
.get(track_name)
.map(|conns| {
conns
.iter()
.filter(|c| Self::is_user_connectable_connection(c))
.filter_map(|c| Self::connectable_connection_to_json(c, &id_to_index))
.collect()
})
.unwrap_or_default();
let disabled_defaults_json: Vec<Value> = state
.connectable_connections_by_track
.get(track_name)
.and_then(|actual| {
state
.tracks
.iter()
.find(|t| &t.name == track_name)
.map(|folder| {
Self::disabled_default_connections_json(
folder,
&state.tracks,
actual,
)
})
})
.unwrap_or_default();
let plugin_positions: serde_json::Map<String, Value> = state
.plugin_graph_plugin_positions
.get(track_name)
.map(|positions| {
plugins
.iter()
.enumerate()
.filter_map(|(idx, p)| {
positions
.get(&p.instance_id)
.map(|pos| (idx.to_string(), json!({ "x": pos.x, "y": pos.y })))
})
.collect()
})
.unwrap_or_default();
let visible_controllers = Self::visible_controllers_to_json(
&state.plugin_graph_visible_controllers,
track_name,
&id_to_index,
);
graphs.insert(
track_name.clone(),
json!({
"plugins": plugins_json,
"connections": conns_json,
"connectable_connections": connectable_conns_json,
"disabled_default_connections": disabled_defaults_json,
"plugin_positions": plugin_positions,
"visible_controllers": visible_controllers,
}),
);
}
Value::Object(graphs)
};
let metadata_year = state.session_year.trim().parse::<u64>().ok();
let metadata_track_number = state.session_track_number.trim().parse::<u64>().ok();
let export_hw_out_ports: Vec<usize> = self.export_hw_out_ports.iter().copied().collect();
let result = json!({
"tracks": tracks_json,
"modulators": &self.modulators,
"connections": &state.connections,
"graphs": graphs,
"metadata": {
"author": state.session_author.clone(),
"album": state.session_album.clone(),
"year": metadata_year,
"track_number": metadata_track_number,
"genre": state.session_genre.clone(),
},
"transport": {
"loop_range_samples": self.loop_range_samples.map(|(start, end)| vec![start, end]),
"loop_enabled": self.loop_enabled,
"punch_range_samples": self.punch_range_samples.map(|(start, end)| vec![start, end]),
"punch_enabled": self.punch_enabled,
"sample_rate_hz": state.hw_sample_rate_hz,
"period_frames": state.oss_period_frames,
"tempo": state.tempo,
"time_signature_num": state.time_signature_num,
"time_signature_denom": state.time_signature_denom,
"tempo_points": state
.tempo_points
.iter()
.map(|p| json!({ "sample": p.sample, "bpm": p.bpm }))
.collect::<Vec<_>>(),
"time_signature_points": state
.time_signature_points
.iter()
.map(|p| {
json!({
"sample": p.sample,
"numerator": p.numerator,
"denominator": p.denominator
})
})
.collect::<Vec<_>>(),
},
"ui": {
"tracks_width": tracks_width,
"live_view_tracks_width": live_view_tracks_width,
"live_view_left_split": live_view_left_split,
"mixer_height": mixer_height,
"zoom_visible_bars": self.zoom_visible_bars,
"snap_mode": self.snap_mode,
"midi_snap_mode": self.midi_snap_mode,
},
"export": {
"sample_rate_hz": self.export_sample_rate_hz,
"format_wav": self.export_format_wav,
"format_mp3": self.export_format_mp3,
"format_ogg": self.export_format_ogg,
"format_flac": self.export_format_flac,
"bit_depth": Self::export_bit_depth_to_json(self.export_bit_depth),
"mp3_mode": Self::export_mp3_mode_to_json(self.export_mp3_mode),
"mp3_bitrate_kbps": self.export_mp3_bitrate_kbps,
"ogg_quality_input": self.export_ogg_quality_input,
"render_mode": Self::export_render_mode_to_json(self.export_render_mode),
"hw_out_ports": export_hw_out_ports,
"realtime_fallback": self.export_realtime_fallback,
"normalize": self.export_normalize,
"normalize_mode": Self::export_normalize_mode_to_json(self.export_normalize_mode),
"normalize_dbfs_input": self.export_normalize_dbfs_input,
"normalize_lufs_input": self.export_normalize_lufs_input,
"normalize_dbtp_input": self.export_normalize_dbtp_input,
"normalize_tp_limiter": self.export_normalize_tp_limiter,
"master_limiter": self.export_master_limiter,
"master_limiter_ceiling_input": self.export_master_limiter_ceiling_input,
},
"midi_learn_global": {
"play_pause": state.global_midi_learn_play_pause,
"stop": state.global_midi_learn_stop,
"record_toggle": state.global_midi_learn_record_toggle,
},
"midi_learn_session": Self::session_midi_learn_json(&state)
});
serde_json::to_writer_pretty(file, &result)?;
Ok(())
}
fn session_midi_learn_json(
state: &crate::state::StateData,
) -> serde_json::Map<String, serde_json::Value> {
use serde_json::{Map, Value};
let mut slots = Map::new();
for ((track_name, scene_index), binding) in &state.session_midi_learn_slots {
let entry = slots
.entry(track_name.clone())
.or_insert_with(|| Value::Object(Map::new()));
if let Value::Object(map) = entry {
map.insert(
scene_index.to_string(),
serde_json::to_value(binding).unwrap_or(Value::Null),
);
}
}
let mut scenes = Map::new();
for (scene_index, binding) in &state.session_midi_learn_scenes {
scenes.insert(
scene_index.to_string(),
serde_json::to_value(binding).unwrap_or(Value::Null),
);
}
let mut stop_track = Map::new();
for (track_name, binding) in &state.session_midi_learn_stop_track {
stop_track.insert(
track_name.clone(),
serde_json::to_value(binding).unwrap_or(Value::Null),
);
}
let mut map = Map::new();
map.insert("slots".to_string(), Value::Object(slots));
map.insert("scenes".to_string(), Value::Object(scenes));
map.insert("stop_track".to_string(), Value::Object(stop_track));
map.insert(
"stop_all".to_string(),
serde_json::to_value(&state.session_midi_learn_stop_all).unwrap_or(Value::Null),
);
map
}
pub(super) fn refresh_graph_then_save_track_template(
&mut self,
track_name: String,
path: String,
) -> Task<Message> {
self.pending_save_path = Some(path.clone());
self.pending_save_track_name = Some(track_name.clone());
let track_names: std::collections::HashSet<String> = self
.collect_folder_member_names(&track_name)
.into_iter()
.collect();
self.pending_save_tracks = track_names.clone();
self.pending_save_clap_tracks = track_names.clone();
self.pending_save_clap_clips.clear();
self.pending_save_is_template = false;
let tasks: Vec<Task<Message>> = track_names
.into_iter()
.flat_map(|name| {
vec![
self.send(Action::TrackGetPluginGraph {
track_name: name.clone(),
include_state: true,
}),
self.send(Action::TrackSnapshotAllClapStates { track_name: name }),
]
})
.collect();
self.state.blocking_write().message = format!("Saving track template for {}", track_name);
Task::batch(tasks)
}
pub(super) fn load_track_template(
&mut self,
track_name: String,
template_name: String,
) -> Task<Message> {
use std::fs::File;
use std::io::BufReader;
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let template_path = format!(
"{}/.config/maolan/track_templates/{}/track.json",
home, template_name
);
let file = match File::open(&template_path) {
Ok(f) => f,
Err(e) => {
return Task::done(Message::Response(Err(format!(
"Failed to open template: {}",
e
))));
}
};
let reader = BufReader::new(file);
let json: serde_json::Value = match serde_json::from_reader(reader) {
Ok(j) => j,
Err(e) => {
return Task::done(Message::Response(Err(format!(
"Failed to parse template: {}",
e
))));
}
};
if let Some(value) = json["track"]["midi"].get("editor_view_mode")
&& let Ok(mode) =
serde_json::from_value::<crate::message::MidiEditorViewMode>(value.clone())
{
let mut state = self.state.blocking_write();
if let Some(track) = state.tracks.iter_mut().find(|t| t.name == track_name) {
track.midi.editor_view_mode = mode;
}
}
let restore_actions = self.build_track_template_actions(&track_name, &json);
if restore_actions.is_empty() {
Task::done(Message::None)
} else {
Self::restore_actions_task(restore_actions)
}
}
fn build_track_template_actions(&self, track_name: &str, json: &Value) -> Vec<Action> {
let mut restore_actions = vec![];
let (vst3_plugins, clap_plugins) = {
let state = self.state.blocking_read();
(state.vst3_plugins.clone(), state.clap_plugins.clone())
};
if let Some(graph) = json.get("graph").and_then(|g| g.as_object()) {
let mut runtime_nodes = Vec::new();
let mut next_instance_id = 0usize;
if let Some(plugins) = graph.get("plugins").and_then(|p| p.as_array()) {
for plugin in plugins {
if let Some(uri) = plugin.get("uri").and_then(|u| u.as_str()) {
match plugin.get("format").and_then(Value::as_str) {
#[cfg(all(unix, not(target_os = "macos")))]
Some("LV2") => {
let instance_id = next_instance_id;
next_instance_id += 1;
runtime_nodes.push(
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(
instance_id,
),
);
restore_actions.push(Action::TrackLoadLv2Plugin {
track_name: track_name.to_string(),
plugin_uri: uri.to_string(),
instance_id: Some(instance_id),
});
if let Some(state) =
plugin.get("state").and_then(Self::lv2_state_from_json)
{
restore_actions.push(Action::TrackSetLv2PluginState {
track_name: track_name.to_string(),
instance_id,
state,
});
}
}
Some("VST3") => {
let instance_id = next_instance_id;
next_instance_id += 1;
runtime_nodes.push(
maolan_engine::message::PluginGraphNode::Vst3PluginInstance(
instance_id,
),
);
if let Some(plugin_path) =
Self::resolve_vst3_plugin_path(uri, &vst3_plugins)
{
restore_actions.push(Action::TrackLoadVst3Plugin {
track_name: track_name.to_string(),
plugin_path,
instance_id: Some(instance_id),
});
}
}
Some("CLAP") => {
let instance_id = next_instance_id;
next_instance_id += 1;
runtime_nodes.push(
maolan_engine::message::PluginGraphNode::ClapPluginInstance(
instance_id,
),
);
if let Some(plugin_path) =
Self::resolve_clap_plugin_path(uri, &clap_plugins)
{
restore_actions.push(Action::TrackLoadClapPlugin {
track_name: track_name.to_string(),
plugin_path,
instance_id: Some(instance_id),
});
if let Some(state) =
plugin.get("state").and_then(Self::clap_state_from_json)
{
restore_actions.push(Action::TrackClapRestoreState {
track_name: track_name.to_string(),
instance_id,
state,
});
}
}
}
_ => {}
}
}
}
}
if let Some(connections) = graph.get("connections").and_then(|c| c.as_array()) {
for conn in connections {
let Some(from_node) = Self::plugin_node_from_json_with_runtime_nodes(
&conn["from_node"],
&runtime_nodes,
) else {
continue;
};
let Some(to_node) = Self::plugin_node_from_json_with_runtime_nodes(
&conn["to_node"],
&runtime_nodes,
) else {
continue;
};
let Some(kind) = Self::kind_from_json(&conn["kind"]) else {
continue;
};
let from_port = conn["from_port"].as_u64().unwrap_or(0) as usize;
let to_port = conn["to_port"].as_u64().unwrap_or(0) as usize;
restore_actions.push(match kind {
Kind::Audio => Action::TrackConnectPluginAudio {
track_name: track_name.to_string(),
from_node,
from_port,
to_node,
to_port,
},
Kind::MIDI => Action::TrackConnectPluginMidi {
track_name: track_name.to_string(),
from_node,
from_port,
to_node,
to_port,
},
});
}
}
if let Some(positions) = graph.get("plugin_positions").and_then(Value::as_object) {
let index_to_id: std::collections::HashMap<usize, usize> = runtime_nodes
.iter()
.enumerate()
.filter_map(|(idx, node)| match node {
maolan_engine::message::PluginGraphNode::ClapPluginInstance(id)
| maolan_engine::message::PluginGraphNode::Vst3PluginInstance(id) => {
Some((idx, *id))
}
#[cfg(all(unix, not(target_os = "macos")))]
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(id) => {
Some((idx, *id))
}
_ => None,
})
.collect();
for (idx_str, pos_v) in positions {
if let (Some(idx), Some(x), Some(y)) = (
idx_str.parse::<usize>().ok(),
pos_v["x"].as_f64(),
pos_v["y"].as_f64(),
) && let Some(&instance_id) = index_to_id.get(&idx)
{
self.state
.blocking_write()
.plugin_graph_plugin_positions
.entry(track_name.to_string())
.or_default()
.insert(instance_id, Point::new(x as f32, y as f32));
}
}
}
if let Some(controllers_v) = graph.get("visible_controllers") {
let index_to_id: std::collections::HashMap<usize, usize> = runtime_nodes
.iter()
.enumerate()
.filter_map(|(idx, node)| match node {
maolan_engine::message::PluginGraphNode::ClapPluginInstance(id)
| maolan_engine::message::PluginGraphNode::Vst3PluginInstance(id) => {
Some((idx, *id))
}
#[cfg(all(unix, not(target_os = "macos")))]
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(id) => {
Some((idx, *id))
}
_ => None,
})
.collect();
let controllers = Self::visible_controllers_from_json(controllers_v, &index_to_id);
if !controllers.is_empty() {
self.state
.blocking_write()
.plugin_graph_visible_controllers
.entry(track_name.to_string())
.or_default()
.extend(controllers);
}
}
if let Some(connectable_arr) = graph
.get("connectable_connections")
.and_then(Value::as_array)
{
let index_to_id: std::collections::HashMap<usize, usize> = runtime_nodes
.iter()
.enumerate()
.filter_map(|(idx, node)| match node {
maolan_engine::message::PluginGraphNode::ClapPluginInstance(id)
| maolan_engine::message::PluginGraphNode::Vst3PluginInstance(id) => {
Some((idx, *id))
}
#[cfg(all(unix, not(target_os = "macos")))]
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(id) => {
Some((idx, *id))
}
_ => None,
})
.collect();
for conn in Self::connectable_connections_from_json(
&Value::Array(connectable_arr.clone()),
&index_to_id,
) {
match conn.kind {
Kind::Audio => restore_actions.push(Action::TrackConnectAudio {
track_name: track_name.to_string(),
from: conn.from,
from_port: conn.from_port,
to: conn.to,
to_port: conn.to_port,
}),
Kind::MIDI => restore_actions.push(Action::TrackConnectMidi {
track_name: track_name.to_string(),
from: conn.from,
from_port: conn.from_port,
to: conn.to,
to_port: conn.to_port,
}),
}
}
}
Self::apply_disabled_default_connections(
track_name,
&Value::Object(graph.clone()),
&mut restore_actions,
);
}
restore_actions
}
fn track_template_path(template_name: &str) -> String {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
format!(
"{}/.config/maolan/track_templates/{}/track.json",
home, template_name
)
}
fn read_track_template_json(template_name: &str) -> Option<Value> {
let path = Self::track_template_path(template_name);
let file = File::open(&path).ok()?;
let reader = BufReader::new(file);
serde_json::from_reader(reader).ok()
}
fn flatten_folder_template(
&self,
value: &Value,
parent_new_name: Option<String>,
existing: &mut std::collections::HashSet<String>,
members: &mut Vec<crate::state::PendingFolderTemplateMember>,
) {
let original_name = value["track"]["name"].as_str().unwrap_or("").to_string();
let new_name = Self::unique_track_name(&original_name, existing);
members.push(crate::state::PendingFolderTemplateMember {
new_name: new_name.clone(),
track_json: value.clone(),
parent_new_name,
});
if let Some(children) = value["children"].as_array() {
for child in children {
self.flatten_folder_template(child, Some(new_name.clone()), existing, members);
}
}
}
fn remap_connectable_ref(v: &mut Value, name_map: &std::collections::HashMap<String, String>) {
if let Some(obj) = v.as_object_mut()
&& obj.get("type").and_then(Value::as_str) == Some("child_track")
&& let Some(name) = obj.get("name").and_then(Value::as_str)
&& let Some(new) = name_map.get(name)
{
obj.insert("name".to_string(), Value::String(new.clone()));
}
}
fn remap_child_track_names_in_graph(
graph: &mut Value,
name_map: &std::collections::HashMap<String, String>,
) {
if let Some(arr) = graph
.get_mut("connectable_connections")
.and_then(Value::as_array_mut)
{
for conn in arr {
if let Some(obj) = conn.as_object_mut() {
Self::remap_connectable_ref(&mut obj["from"], name_map);
Self::remap_connectable_ref(&mut obj["to"], name_map);
}
}
}
if let Some(arr) = graph
.get_mut("disabled_default_connections")
.and_then(Value::as_array_mut)
{
for entry in arr {
if let Some(name) = entry.get("child").and_then(Value::as_str)
&& let Some(new) = name_map.get(name)
{
entry["child"] = Value::String(new.clone());
}
}
}
}
pub(super) fn apply_folder_template(
&mut self,
target_name: String,
template_name: String,
) -> Task<Message> {
let Some(json) = Self::read_track_template_json(&template_name) else {
return Task::done(Message::Response(Err(format!(
"Failed to read folder template '{}'",
template_name
))));
};
let mut existing: std::collections::HashSet<String> = self
.state
.blocking_read()
.tracks
.iter()
.map(|t| t.name.clone())
.collect();
let mut members = Vec::new();
self.flatten_folder_template(&json, None, &mut existing, &mut members);
if members.is_empty() {
return Task::done(Message::Response(Err(
"Folder template has no tracks".to_string()
)));
}
let mut tasks = Vec::new();
for member in &members {
let track_json = &member.track_json["track"];
let audio_ins = track_json["audio"]["ins"].as_u64().unwrap_or(0) as usize;
let audio_outs = track_json["audio"]["outs"].as_u64().unwrap_or(0) as usize;
let midi_ins = track_json["midi"]["ins"].as_u64().unwrap_or(0) as usize;
let midi_outs = track_json["midi"]["outs"].as_u64().unwrap_or(0) as usize;
let folder = track_json["is_folder"].as_bool().unwrap_or(false);
tasks.push(self.send(Action::AddTrack {
name: member.new_name.clone(),
audio_ins,
midi_ins,
audio_outs,
midi_outs,
folder,
}));
}
let remaining: std::collections::HashSet<String> =
members.iter().map(|m| m.new_name.clone()).collect();
self.state
.blocking_write()
.pending_folder_template_loads
.push(crate::state::PendingFolderTemplateLoad {
target_name,
template_name,
remaining,
members,
});
Task::batch(tasks)
}
pub(super) fn complete_folder_template_load(
&mut self,
load: &crate::state::PendingFolderTemplateLoad,
) -> Task<Message> {
let name_map: std::collections::HashMap<String, String> = load
.members
.iter()
.map(|m| {
let original = m.track_json["track"]["name"]
.as_str()
.unwrap_or("")
.to_string();
(original, m.new_name.clone())
})
.collect();
let mut actions = Vec::new();
for member in &load.members {
if let Some(parent_new_name) = &member.parent_new_name {
actions.push(Action::TrackSetParent {
track_name: member.new_name.clone(),
parent_name: Some(parent_new_name.clone()),
});
}
}
for member in &load.members {
let mut member_json = member.track_json.clone();
if let Some(graph) = member_json.get_mut("graph") {
Self::remap_child_track_names_in_graph(graph, &name_map);
}
actions.extend(self.build_track_template_actions(&member.new_name, &member_json));
}
if let Some(connections) = load.members[0]
.track_json
.get("connections")
.and_then(Value::as_array)
{
for conn in connections {
if let Ok(mut connection) =
serde_json::from_value::<crate::state::Connection>(conn.clone())
{
if let Some(new) = name_map.get(&connection.from_track) {
connection.from_track = new.clone();
}
if let Some(new) = name_map.get(&connection.to_track) {
connection.to_track = new.clone();
}
actions.push(Action::Connect {
from_track: connection.from_track,
from_port: connection.from_port,
to_track: connection.to_track,
to_port: connection.to_port,
kind: connection.kind,
});
}
}
}
self.state
.blocking_write()
.message
.clone_from(&format!("Applied folder template '{}'", load.template_name));
Self::restore_actions_task(actions)
}
pub(super) fn copy_track_from_branch(
&self,
session_root: &std::path::Path,
source_branch: &str,
source_track_name: &str,
) -> Result<Task<Message>, String> {
use std::collections::HashSet;
use std::fs::File;
use std::io::BufReader;
let current_path = session_root.join(format!("{}.json", self.session_branch));
let source_path = session_root.join(format!("{}.json", source_branch));
let current_json: serde_json::Value = {
let file = File::open(¤t_path)
.map_err(|e| format!("Failed to open current branch: {e}"))?;
serde_json::from_reader(BufReader::new(file))
.map_err(|e| format!("Failed to parse current branch: {e}"))?
};
let source_json: serde_json::Value = {
let file = File::open(&source_path)
.map_err(|e| format!("Failed to open source branch: {e}"))?;
serde_json::from_reader(BufReader::new(file))
.map_err(|e| format!("Failed to parse source branch: {e}"))?
};
let source_tracks = source_json
.get("tracks")
.and_then(|v| v.as_array())
.ok_or_else(|| "No tracks in source branch".to_string())?;
let source_track = source_tracks
.iter()
.find(|t| t.get("name").and_then(|v| v.as_str()) == Some(source_track_name))
.ok_or_else(|| {
format!(
"Track '{}' not found in branch '{}'",
source_track_name, source_branch
)
})?
.clone();
let current_tracks = current_json
.get("tracks")
.and_then(|v| v.as_array())
.ok_or_else(|| "No tracks in current branch".to_string())?;
let existing_names: HashSet<String> = current_tracks
.iter()
.filter_map(|t| t.get("name").and_then(|v| v.as_str()).map(String::from))
.collect();
let target_name = if existing_names.contains(source_track_name) {
let mut n = 2;
loop {
let candidate = format!("{} {}", source_track_name, n);
if !existing_names.contains(&candidate) {
break candidate;
}
n += 1;
}
} else {
source_track_name.to_string()
};
let mut target_track = source_track;
if let Some(obj) = target_track.as_object_mut() {
obj.insert(
"name".to_string(),
serde_json::Value::String(target_name.clone()),
);
}
let mut new_current = current_json;
if let Some(tracks) = new_current.get_mut("tracks").and_then(|v| v.as_array_mut()) {
tracks.push(target_track);
}
if let Some(graphs) = source_json.get("graphs").and_then(|v| v.as_object())
&& let Some(track_graph) = graphs.get(source_track_name)
&& let Some(new_graphs) = new_current
.get_mut("graphs")
.and_then(|v| v.as_object_mut())
{
new_graphs.insert(target_name.clone(), track_graph.clone());
}
let current_connections = new_current
.get_mut("connections")
.and_then(|v| v.as_array_mut())
.ok_or_else(|| "No connections in current branch".to_string())?;
let existing_conns: HashSet<String> =
current_connections.iter().map(|c| c.to_string()).collect();
if let Some(connections) = source_json.get("connections").and_then(|v| v.as_array()) {
for conn in connections {
let Some(conn_obj) = conn.as_object() else {
continue;
};
let from_track = conn_obj.get("from_track").and_then(|v| v.as_str());
let to_track = conn_obj.get("to_track").and_then(|v| v.as_str());
let involves_source =
from_track == Some(source_track_name) || to_track == Some(source_track_name);
if !involves_source {
continue;
}
let other_track = if from_track == Some(source_track_name) {
to_track
} else {
from_track
};
let other_exists = other_track
.map(|name| existing_names.contains(name))
.unwrap_or(true);
if !other_exists {
continue;
}
let mut new_conn = conn.clone();
if let Some(new_conn_obj) = new_conn.as_object_mut() {
if from_track == Some(source_track_name) {
new_conn_obj.insert(
"from_track".to_string(),
serde_json::Value::String(target_name.clone()),
);
}
if to_track == Some(source_track_name) {
new_conn_obj.insert(
"to_track".to_string(),
serde_json::Value::String(target_name.clone()),
);
}
}
let conn_str = new_conn.to_string();
if !existing_conns.contains(&conn_str) {
current_connections.push(new_conn);
}
}
}
let file = File::create(¤t_path)
.map_err(|e| format!("Failed to write current branch: {e}"))?;
serde_json::to_writer_pretty(file, &new_current)
.map_err(|e| format!("Failed to serialize current branch: {e}"))?;
let session_root = session_root.to_path_buf();
Ok(Task::perform(
async move { session_root },
crate::message::Message::LoadSessionPath,
))
}
fn collect_folder_member_names(&self, root_name: &str) -> Vec<String> {
let state = self.state.blocking_read();
let mut result = vec![root_name.to_string()];
let mut i = 0;
while i < result.len() {
let parent = result[i].clone();
for track in &state.tracks {
if track.parent_track.as_deref() == Some(&parent) && !result.contains(&track.name) {
result.push(track.name.clone());
}
}
i += 1;
}
result
}
fn track_template_json(
&self,
track_name: &str,
state: &crate::state::StateData,
) -> Option<Value> {
let track = state.tracks.iter().find(|t| t.name == track_name)?;
let mut track_json = serde_json::to_value(track).ok()?;
if let Some(audio) = track_json.get_mut("audio").and_then(Value::as_object_mut) {
audio.insert("clips".to_string(), Value::Array(vec![]));
}
if let Some(midi) = track_json.get_mut("midi").and_then(Value::as_object_mut) {
midi.insert("clips".to_string(), Value::Array(vec![]));
}
let graph = {
if let Some((plugins, connections)) = state.plugin_graphs_by_track.get(track_name) {
let id_to_index: std::collections::HashMap<usize, usize> = plugins
.iter()
.enumerate()
.map(|(idx, p)| (p.instance_id, idx))
.collect();
let plugins_json: Vec<Value> = plugins
.iter()
.map(|p| {
let state_json = p.state.clone().unwrap_or(Value::Null);
json!({"format": p.format, "uri": Self::plugin_save_uri(p), "state": state_json})
})
.collect();
let conns_json: Vec<Value> = connections
.iter()
.filter_map(|c| {
let from_node = Self::plugin_node_to_json(&c.from_node, &id_to_index)?;
let to_node = Self::plugin_node_to_json(&c.to_node, &id_to_index)?;
Some(json!({
"from_node": from_node,
"from_port": c.from_port,
"to_node": to_node,
"to_port": c.to_port,
"kind": Self::kind_to_json(c.kind),
}))
})
.collect();
let connectable_conns_json: Vec<Value> = state
.connectable_connections_by_track
.get(track_name)
.map(|conns| {
conns
.iter()
.filter(|c| Self::is_user_connectable_connection(c))
.filter_map(|c| Self::connectable_connection_to_json(c, &id_to_index))
.collect()
})
.unwrap_or_default();
let disabled_defaults_json: Vec<Value> = state
.connectable_connections_by_track
.get(track_name)
.and_then(|actual| {
state
.tracks
.iter()
.find(|t| t.name == track_name)
.map(|folder| {
Self::disabled_default_connections_json(
folder,
&state.tracks,
actual,
)
})
})
.unwrap_or_default();
let visible_controllers = Self::visible_controllers_to_json(
&state.plugin_graph_visible_controllers,
track_name,
&id_to_index,
);
json!({
"plugins": plugins_json,
"connections": conns_json,
"connectable_connections": connectable_conns_json,
"disabled_default_connections": disabled_defaults_json,
"visible_controllers": visible_controllers,
})
} else {
Value::Null
}
};
Some(json!({
"track": track_json,
"graph": graph,
}))
}
fn build_folder_template_children(
&self,
parent_name: &str,
state: &crate::state::StateData,
) -> Vec<Value> {
state
.tracks
.iter()
.filter(|t| t.parent_track.as_deref() == Some(parent_name))
.map(|child| {
let mut value = self
.track_template_json(&child.name, state)
.unwrap_or(Value::Null);
if let Some(obj) = value.as_object_mut() {
let children = self.build_folder_template_children(&child.name, state);
if !children.is_empty() {
obj.insert("children".to_string(), Value::Array(children));
}
}
value
})
.collect()
}
pub(super) fn save_track_as_template(&self, track_name: &str, path: String) -> Task<Message> {
let result = (|| -> std::io::Result<()> {
let template_root = PathBuf::from(&path);
fs::create_dir_all(&path)?;
fs::create_dir_all(template_root.join("plugins"))?;
let mut p = template_root.clone();
p.push("track.json");
let file = File::create(&p)?;
let state = self.state.blocking_read();
let track = state
.tracks
.iter()
.find(|t| t.name == track_name)
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Track not found"))?;
let mut result = self
.track_template_json(track_name, &state)
.unwrap_or(Value::Null);
if track.is_folder {
if let Some(obj) = result.as_object_mut() {
let children = self.build_folder_template_children(track_name, &state);
if !children.is_empty() {
obj.insert("children".to_string(), Value::Array(children));
}
let member_names: std::collections::HashSet<String> = self
.collect_folder_member_names(track_name)
.into_iter()
.collect();
let internal_connections: Vec<&crate::state::Connection> = state
.connections
.iter()
.filter(|c| {
member_names.contains(&c.from_track)
&& member_names.contains(&c.to_track)
})
.collect();
obj.insert(
"connections".to_string(),
serde_json::to_value(internal_connections).unwrap_or(Value::Array(vec![])),
);
}
} else {
let track_connections: Vec<&crate::state::Connection> = state
.connections
.iter()
.filter(|c| c.from_track == track_name || c.to_track == track_name)
.collect();
if let Some(obj) = result.as_object_mut() {
obj.insert(
"connections".to_string(),
serde_json::to_value(track_connections).unwrap_or(Value::Array(vec![])),
);
}
}
serde_json::to_writer_pretty(file, &result)?;
Ok(())
})();
if let Err(e) = result {
Task::done(Message::Response(Err(format!(
"Failed to save track template: {}",
e
))))
} else {
Task::done(Message::None)
}
}
pub(super) fn save(&self, path: String) -> std::io::Result<()> {
let filename = format!("{}.json", self.session_branch);
let session_root = PathBuf::from(path.clone());
let mut p = session_root.clone();
p.push(&filename);
fs::create_dir_all(&path)?;
fs::create_dir_all(session_root.join("plugins"))?;
fs::create_dir_all(session_root.join("data"))?;
fs::create_dir_all(session_root.join("audio"))?;
fs::create_dir_all(session_root.join("midi"))?;
fs::create_dir_all(session_root.join("peaks"))?;
fs::create_dir_all(session_root.join("pitch"))?;
{
let mut state = self.state.blocking_write();
for track in &mut state.tracks {
for clip in &mut track.audio.clips {
clip.ensure_id();
}
for clip in &mut track.midi.clips {
clip.ensure_id();
}
}
}
let file = File::create(&p)?;
let state = self.state.blocking_read();
let tracks_width = match state.tracks_width {
Length::Fixed(v) => v,
_ => 200.0,
};
let live_view_tracks_width = match state.live_view_tracks_width {
Length::Fixed(v) => v,
_ => 200.0,
};
let live_view_left_split = state.live_view_left_split;
let mixer_height = match state.mixer_height {
Length::Fixed(v) => v,
_ => 300.0,
};
let mut tracks_json = serde_json::to_value(&state.tracks).map_err(io::Error::other)?;
if let Some(tracks) = tracks_json.as_array_mut() {
for (track_idx, track) in tracks.iter_mut().enumerate() {
let track_name = track["name"].as_str().unwrap_or("").to_string();
let state_track = state.tracks.get(track_idx);
if let Some(audio_clips) = track
.get_mut("audio")
.and_then(|m| m.get_mut("clips"))
.and_then(Value::as_array_mut)
{
for (clip_idx, clip) in audio_clips.iter_mut().enumerate() {
let clip_name = clip
.get("name")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let mut peaks = state_track
.and_then(|t| t.audio.clips.get(clip_idx))
.map(|c| c.peaks.clone())
.unwrap_or_default();
if peaks.is_empty() && clip_name.to_ascii_lowercase().ends_with(".wav") {
let wav_path = session_root.join(&clip_name);
if wav_path.exists()
&& let Ok(computed) = Self::compute_audio_clip_peaks(&wav_path)
{
peaks = computed;
}
}
if !peaks.is_empty() {
let rel = Self::build_peak_file_rel(&track_name, clip_idx, &clip_name);
let abs = session_root.join(&rel);
let peak_json = json!({
"version": 1,
"track": track_name,
"clip": clip_name,
"peaks": peaks.as_ref(),
});
let peak_file = File::create(&abs)?;
serde_json::to_writer_pretty(BufWriter::new(peak_file), &peak_json)?;
clip["peaks_file"] = Value::String(rel);
}
}
}
let Some(midi_clips) = track
.get_mut("midi")
.and_then(|m| m.get_mut("clips"))
.and_then(Value::as_array_mut)
else {
continue;
};
for clip in midi_clips {
let Some(name) = clip.get("name").and_then(Value::as_str) else {
continue;
};
let lower = name.to_ascii_lowercase();
if !(lower.ends_with(".mid") || lower.ends_with(".midi")) {
continue;
}
let src_path = {
let p = PathBuf::from(name);
if p.is_absolute() {
p
} else {
let in_session = session_root.join(&p);
if in_session.exists() { in_session } else { p }
}
};
let basename = Path::new(name)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("clip.mid");
let rel = format!("midi/{basename}");
let dst_path = session_root.join(&rel);
if src_path.exists()
&& src_path.is_file()
&& src_path != dst_path
&& let Err(_err) = fs::copy(&src_path, &dst_path)
{}
clip["name"] = Value::String(rel);
}
}
}
let graphs = {
let mut graphs = serde_json::Map::new();
for (track_name, (plugins, connections)) in &state.plugin_graphs_by_track {
let id_to_index: std::collections::HashMap<usize, usize> = plugins
.iter()
.enumerate()
.map(|(idx, p)| (p.instance_id, idx))
.collect();
let plugins_json: Vec<Value> = plugins
.iter()
.map(|p| {
let state_json = p.state.clone().unwrap_or(Value::Null);
let has_state = !state_json.is_null();
tracing::info!(%track_name, instance_id = p.instance_id, format = %p.format, has_state, "save: writing plugin state");
json!({"format": p.format, "uri": Self::plugin_save_uri(p), "state": state_json})
})
.collect();
let conns_json: Vec<Value> = connections
.iter()
.filter_map(|c| {
let from_node = Self::plugin_node_to_json(&c.from_node, &id_to_index)?;
let to_node = Self::plugin_node_to_json(&c.to_node, &id_to_index)?;
Some(json!({
"from_node": from_node,
"from_port": c.from_port,
"to_node": to_node,
"to_port": c.to_port,
"kind": Self::kind_to_json(c.kind),
}))
})
.collect();
let connectable_conns_json: Vec<Value> = state
.connectable_connections_by_track
.get(track_name)
.map(|conns| {
conns
.iter()
.filter(|c| Self::is_user_connectable_connection(c))
.filter_map(|c| Self::connectable_connection_to_json(c, &id_to_index))
.collect()
})
.unwrap_or_default();
let disabled_defaults_json: Vec<Value> = state
.connectable_connections_by_track
.get(track_name)
.and_then(|actual| {
state
.tracks
.iter()
.find(|t| &t.name == track_name)
.map(|folder| {
Self::disabled_default_connections_json(
folder,
&state.tracks,
actual,
)
})
})
.unwrap_or_default();
let plugin_positions: serde_json::Map<String, Value> = state
.plugin_graph_plugin_positions
.get(track_name)
.map(|positions| {
plugins
.iter()
.enumerate()
.filter_map(|(idx, p)| {
positions
.get(&p.instance_id)
.map(|pos| (idx.to_string(), json!({ "x": pos.x, "y": pos.y })))
})
.collect()
})
.unwrap_or_default();
let visible_controllers = Self::visible_controllers_to_json(
&state.plugin_graph_visible_controllers,
track_name,
&id_to_index,
);
graphs.insert(
track_name.clone(),
json!({
"plugins": plugins_json,
"connections": conns_json,
"connectable_connections": connectable_conns_json,
"disabled_default_connections": disabled_defaults_json,
"plugin_positions": plugin_positions,
"visible_controllers": visible_controllers,
}),
);
}
Value::Object(graphs)
};
let metadata_year = state.session_year.trim().parse::<u64>().ok();
let metadata_track_number = state.session_track_number.trim().parse::<u64>().ok();
let export_hw_out_ports: Vec<usize> = self.export_hw_out_ports.iter().copied().collect();
let result = json!({
"tracks": tracks_json,
"modulators": &self.modulators,
"connections": &state.connections,
"graphs": graphs,
"metadata": {
"author": state.session_author.clone(),
"album": state.session_album.clone(),
"year": metadata_year,
"track_number": metadata_track_number,
"genre": state.session_genre.clone(),
},
"transport": {
"loop_range_samples": self.loop_range_samples.map(|(start, end)| vec![start, end]),
"loop_enabled": self.loop_enabled,
"punch_range_samples": self.punch_range_samples.map(|(start, end)| vec![start, end]),
"punch_enabled": self.punch_enabled,
"sample_rate_hz": state.hw_sample_rate_hz,
"period_frames": state.oss_period_frames,
"tempo": state.tempo,
"time_signature_num": state.time_signature_num,
"time_signature_denom": state.time_signature_denom,
"tempo_points": state
.tempo_points
.iter()
.map(|p| json!({ "sample": p.sample, "bpm": p.bpm }))
.collect::<Vec<_>>(),
"time_signature_points": state
.time_signature_points
.iter()
.map(|p| {
json!({
"sample": p.sample,
"numerator": p.numerator,
"denominator": p.denominator
})
})
.collect::<Vec<_>>(),
},
"ui": {
"tracks_width": tracks_width,
"live_view_tracks_width": live_view_tracks_width,
"live_view_left_split": live_view_left_split,
"mixer_height": mixer_height,
"zoom_visible_bars": self.zoom_visible_bars,
"snap_mode": self.snap_mode,
"midi_snap_mode": self.midi_snap_mode,
},
"export": {
"sample_rate_hz": self.export_sample_rate_hz,
"format_wav": self.export_format_wav,
"format_mp3": self.export_format_mp3,
"format_ogg": self.export_format_ogg,
"format_flac": self.export_format_flac,
"bit_depth": Self::export_bit_depth_to_json(self.export_bit_depth),
"mp3_mode": Self::export_mp3_mode_to_json(self.export_mp3_mode),
"mp3_bitrate_kbps": self.export_mp3_bitrate_kbps,
"ogg_quality_input": self.export_ogg_quality_input,
"render_mode": Self::export_render_mode_to_json(self.export_render_mode),
"hw_out_ports": export_hw_out_ports,
"realtime_fallback": self.export_realtime_fallback,
"normalize": self.export_normalize,
"normalize_mode": Self::export_normalize_mode_to_json(self.export_normalize_mode),
"normalize_dbfs_input": self.export_normalize_dbfs_input,
"normalize_lufs_input": self.export_normalize_lufs_input,
"normalize_dbtp_input": self.export_normalize_dbtp_input,
"normalize_tp_limiter": self.export_normalize_tp_limiter,
"master_limiter": self.export_master_limiter,
"master_limiter_ceiling_input": self.export_master_limiter_ceiling_input,
},
"midi_learn_global": {
"play_pause": state.global_midi_learn_play_pause,
"stop": state.global_midi_learn_stop,
"record_toggle": state.global_midi_learn_record_toggle,
},
"midi_learn_session": Self::session_midi_learn_json(&state)
});
serde_json::to_writer_pretty(file, &result)?;
let session_json_path = session_root.join("session.json");
let session_file = File::create(&session_json_path)?;
let session_matrix = {
let state = self.state.blocking_read();
state.session.clone()
};
serde_json::to_writer_pretty(
session_file,
&json!({
"version": 1,
"matrix": session_matrix,
}),
)?;
let commit_dir = session_root
.join(".maolan_commits")
.join(&self.session_branch);
fs::create_dir_all(&commit_dir)?;
let commit_filename = chrono::Local::now().format("%Y%m%d-%H%M%S").to_string();
let commit_path = commit_dir.join(format!("{}.json", commit_filename));
fs::copy(&p, &commit_path)?;
Ok(())
}
pub(super) fn load(&mut self, path: String) -> std::io::Result<Task<Message>> {
let load_start = Instant::now();
let mut restore_actions: Vec<Action> = vec![
Action::BeginSessionRestore,
Action::SetSessionPath(path.clone()),
Action::SetGlobalMidiLearnBinding {
target: GlobalMidiLearnTarget::PlayPause,
binding: None,
},
Action::SetGlobalMidiLearnBinding {
target: GlobalMidiLearnTarget::Stop,
binding: None,
},
Action::SetGlobalMidiLearnBinding {
target: GlobalMidiLearnTarget::RecordToggle,
binding: None,
},
];
let mut frozen_tracks: Vec<String> = Vec::new();
let mut warnings: Vec<String> = Vec::new();
let session_root = PathBuf::from(path.clone());
self.pending_peak_file_loads.clear();
self.pending_peak_rebuilds.clear();
self.midi_clip_previews.clear();
self.pending_midi_clip_previews.clear();
self.pending_track_freeze_restore.clear();
self.pending_track_midi_editor_view_mode.clear();
self.pending_track_freeze_bounce.clear();
let existing_tracks: Vec<String> = self
.state
.blocking_read()
.tracks
.iter()
.map(|t| t.name.clone())
.collect();
for name in existing_tracks {
restore_actions.push(Action::RemoveTrack(name));
}
{
let mut state = self.state.blocking_write();
state.connections.clear();
state.selected.clear();
state.selected_clips.clear();
state.connection_view_selection = ConnectionViewSelection::None;
state.clap_plugins_by_track.clear();
state.clap_states_by_track.clear();
state.vst3_states_by_track.clear();
state.global_midi_learn_play_pause = None;
state.global_midi_learn_stop = None;
state.global_midi_learn_record_toggle = None;
state.session_author.clear();
state.session_album.clear();
state.session_year.clear();
state.session_track_number.clear();
state.session_genre.clear();
state.plugin_graphs_by_track.clear();
state.connectable_connections_by_track.clear();
}
let filename = format!("{}.json", self.session_branch);
let mut p = PathBuf::from(path.clone());
p.push(&filename);
let file = File::open(&p)?;
let reader = BufReader::new(file);
let session: Value = serde_json::from_reader(reader)?;
{
let session_json_path = session_root.join("session.json");
let mut state = self.state.blocking_write();
state.session = if session_json_path.exists() {
match File::open(&session_json_path) {
Ok(file) => {
let reader = BufReader::new(file);
match serde_json::from_reader::<_, serde_json::Value>(reader) {
Ok(value) => value
.get("matrix")
.and_then(|matrix| {
serde_json::from_value::<crate::state::SessionMatrix>(
matrix.clone(),
)
.ok()
})
.unwrap_or_default(),
Err(e) => {
tracing::warn!(
"Failed to parse session.json at {}: {}",
session_json_path.display(),
e
);
crate::state::SessionMatrix::default()
}
}
}
Err(e) => {
tracing::warn!(
"Failed to open session.json at {}: {}",
session_json_path.display(),
e
);
crate::state::SessionMatrix::default()
}
}
} else {
crate::state::SessionMatrix::default()
};
state.session.backfill_play_stop_icons();
}
#[cfg(all(unix, not(target_os = "macos")))]
if let Some(graphs) = session.get("graphs").and_then(Value::as_object) {
let (lv2_plugins, vst3_plugins, clap_plugins) = {
let state = self.state.blocking_read();
(
state.lv2_plugins.clone(),
state.vst3_plugins.clone(),
state.clap_plugins.clone(),
)
};
let snapshots = graphs
.iter()
.filter_map(|(track_name, graph)| {
let snapshot = Self::plugin_graph_snapshot_from_json(
Some(graph),
&lv2_plugins,
&vst3_plugins,
&clap_plugins,
);
(!snapshot.0.is_empty() || !snapshot.1.is_empty())
.then(|| (track_name.clone(), snapshot))
})
.collect::<std::collections::HashMap<_, _>>();
self.state
.blocking_write()
.plugin_graphs_by_track
.extend(snapshots);
let mut connectable = self.state.blocking_write();
for (track_name, graph) in graphs {
let snapshot = Self::plugin_graph_snapshot_from_json(
Some(graph),
&lv2_plugins,
&vst3_plugins,
&clap_plugins,
);
let id_to_index: std::collections::HashMap<usize, usize> = snapshot
.0
.iter()
.enumerate()
.map(|(idx, p)| (p.instance_id, idx))
.collect();
let index_to_id: std::collections::HashMap<usize, usize> =
id_to_index.iter().map(|(&id, &idx)| (idx, id)).collect();
let conns = Self::connectable_connections_from_json(
&graph["connectable_connections"],
&index_to_id,
);
if !conns.is_empty() {
connectable
.connectable_connections_by_track
.insert(track_name.clone(), conns);
}
let controllers = Self::visible_controllers_from_json(
&graph["visible_controllers"],
&index_to_id,
);
if !controllers.is_empty() {
connectable
.plugin_graph_visible_controllers
.insert(track_name.clone(), controllers);
}
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
if let Some(graphs) = session.get("graphs").and_then(Value::as_object) {
let (vst3_plugins, clap_plugins) = {
let state = self.state.blocking_read();
(state.vst3_plugins.clone(), state.clap_plugins.clone())
};
let snapshots = graphs
.iter()
.filter_map(|(track_name, graph)| {
let snapshot = Self::plugin_graph_snapshot_from_json(
Some(graph),
&vst3_plugins,
&clap_plugins,
);
(!snapshot.0.is_empty() || !snapshot.1.is_empty())
.then(|| (track_name.clone(), snapshot))
})
.collect::<std::collections::HashMap<_, _>>();
self.state
.blocking_write()
.plugin_graphs_by_track
.extend(snapshots);
let mut connectable = self.state.blocking_write();
for (track_name, graph) in graphs {
let snapshot = Self::plugin_graph_snapshot_from_json(
Some(graph),
&vst3_plugins,
&clap_plugins,
);
let id_to_index: std::collections::HashMap<usize, usize> = snapshot
.0
.iter()
.enumerate()
.map(|(idx, p)| (p.instance_id, idx))
.collect();
let index_to_id: std::collections::HashMap<usize, usize> =
id_to_index.iter().map(|(&id, &idx)| (idx, id)).collect();
let conns = Self::connectable_connections_from_json(
&graph["connectable_connections"],
&index_to_id,
);
if !conns.is_empty() {
connectable
.connectable_connections_by_track
.insert(track_name.clone(), conns);
}
let controllers = Self::visible_controllers_from_json(
&graph["visible_controllers"],
&index_to_id,
);
if !controllers.is_empty() {
connectable
.plugin_graph_visible_controllers
.insert(track_name.clone(), controllers);
}
}
}
if let Some(global_ml) = session.get("midi_learn_global").and_then(Value::as_object) {
if let Ok(binding) =
serde_json::from_value::<Option<maolan_engine::message::MidiLearnBinding>>(
global_ml.get("play_pause").cloned().unwrap_or(Value::Null),
)
&& binding.is_some()
{
restore_actions.push(Action::SetGlobalMidiLearnBinding {
target: GlobalMidiLearnTarget::PlayPause,
binding,
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(global_ml.get("stop").cloned().unwrap_or(Value::Null))
&& binding.is_some()
{
restore_actions.push(Action::SetGlobalMidiLearnBinding {
target: GlobalMidiLearnTarget::Stop,
binding,
});
}
if let Ok(binding) =
serde_json::from_value::<Option<maolan_engine::message::MidiLearnBinding>>(
global_ml
.get("record_toggle")
.cloned()
.unwrap_or(Value::Null),
)
&& binding.is_some()
{
restore_actions.push(Action::SetGlobalMidiLearnBinding {
target: GlobalMidiLearnTarget::RecordToggle,
binding,
});
}
}
if let Some(session_ml) = session.get("midi_learn_session").and_then(Value::as_object) {
if let Some(slots) = session_ml.get("slots").and_then(Value::as_object) {
for (track_name, scenes) in slots {
if let Some(scenes) = scenes.as_object() {
for (scene_index, binding) in scenes {
if let Ok(scene_index) = scene_index.parse::<usize>()
&& let Ok(Some(binding)) =
serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(binding.clone())
{
restore_actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::Slot {
track_name: track_name.clone(),
scene_index,
},
binding: Some(binding),
});
}
}
}
}
}
if let Some(scenes) = session_ml.get("scenes").and_then(Value::as_object) {
for (scene_index, binding) in scenes {
if let Ok(scene_index) = scene_index.parse::<usize>()
&& let Ok(Some(binding)) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(binding.clone())
{
restore_actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::Scene(
scene_index,
),
binding: Some(binding),
});
}
}
}
if let Some(stop_track) = session_ml.get("stop_track").and_then(Value::as_object) {
for (track_name, binding) in stop_track {
if let Ok(Some(binding)) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(binding.clone())
{
restore_actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::StopTrack(
track_name.clone(),
),
binding: Some(binding),
});
}
}
}
if let Some(binding) = session_ml.get("stop_all").cloned()
&& let Ok(Some(binding)) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(binding)
{
restore_actions.push(Action::SetSessionMidiLearnBinding {
target: maolan_engine::message::SessionMidiLearnTarget::StopAll,
binding: Some(binding),
});
}
}
if let Some(metadata) = session.get("metadata").and_then(Value::as_object) {
let mut state = self.state.blocking_write();
state.session_author = metadata
.get("author")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
state.session_album = metadata
.get("album")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
state.session_year = metadata
.get("year")
.and_then(Value::as_u64)
.map(|v| v.to_string())
.or_else(|| {
metadata
.get("year")
.and_then(Value::as_str)
.and_then(|s| s.trim().parse::<u64>().ok().map(|v| v.to_string()))
})
.unwrap_or_default();
state.session_track_number = metadata
.get("track_number")
.and_then(Value::as_u64)
.map(|v| v.to_string())
.or_else(|| {
metadata
.get("track_number")
.and_then(Value::as_str)
.and_then(|s| s.trim().parse::<u64>().ok().map(|v| v.to_string()))
})
.unwrap_or_default();
state.session_genre = metadata
.get("genre")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
}
if let Some(export) = session.get("export").and_then(Value::as_object) {
self.export_sample_rate_hz = export
.get("sample_rate_hz")
.and_then(Value::as_u64)
.map(|v| v.max(1) as u32)
.unwrap_or(self.export_sample_rate_hz);
self.export_format_wav = export
.get("format_wav")
.and_then(Value::as_bool)
.unwrap_or(self.export_format_wav);
self.export_format_mp3 = export
.get("format_mp3")
.and_then(Value::as_bool)
.unwrap_or(self.export_format_mp3);
self.export_format_ogg = export
.get("format_ogg")
.and_then(Value::as_bool)
.unwrap_or(self.export_format_ogg);
self.export_format_flac = export
.get("format_flac")
.and_then(Value::as_bool)
.unwrap_or(self.export_format_flac);
self.export_bit_depth = Self::export_bit_depth_from_json(export.get("bit_depth"));
self.export_mp3_mode = Self::export_mp3_mode_from_json(export.get("mp3_mode"));
self.export_mp3_bitrate_kbps = export
.get("mp3_bitrate_kbps")
.and_then(Value::as_u64)
.map(|v| v.clamp(8, u16::MAX as u64) as u16)
.unwrap_or(self.export_mp3_bitrate_kbps);
self.export_ogg_quality_input = export
.get("ogg_quality_input")
.and_then(Value::as_str)
.unwrap_or(&self.export_ogg_quality_input)
.to_string();
self.export_render_mode = Self::export_render_mode_from_json(export.get("render_mode"));
self.export_hw_out_ports = export
.get("hw_out_ports")
.and_then(Value::as_array)
.map(|ports| {
ports
.iter()
.filter_map(Value::as_u64)
.map(|port| port as usize)
.collect()
})
.unwrap_or_else(|| self.default_export_hw_out_ports());
self.export_realtime_fallback = export
.get("realtime_fallback")
.and_then(Value::as_bool)
.unwrap_or(self.export_realtime_fallback);
self.export_normalize = export
.get("normalize")
.and_then(Value::as_bool)
.unwrap_or(self.export_normalize);
self.export_normalize_mode =
Self::export_normalize_mode_from_json(export.get("normalize_mode"));
self.export_normalize_dbfs_input = export
.get("normalize_dbfs_input")
.and_then(Value::as_str)
.unwrap_or(&self.export_normalize_dbfs_input)
.to_string();
self.export_normalize_lufs_input = export
.get("normalize_lufs_input")
.and_then(Value::as_str)
.unwrap_or(&self.export_normalize_lufs_input)
.to_string();
self.export_normalize_dbtp_input = export
.get("normalize_dbtp_input")
.and_then(Value::as_str)
.unwrap_or(&self.export_normalize_dbtp_input)
.to_string();
self.export_normalize_tp_limiter = export
.get("normalize_tp_limiter")
.and_then(Value::as_bool)
.unwrap_or(self.export_normalize_tp_limiter);
self.export_master_limiter = export
.get("master_limiter")
.and_then(Value::as_bool)
.unwrap_or(self.export_master_limiter);
self.export_master_limiter_ceiling_input = export
.get("master_limiter_ceiling_input")
.and_then(Value::as_str)
.unwrap_or(&self.export_master_limiter_ceiling_input)
.to_string();
} else {
self.export_hw_out_ports = self.default_export_hw_out_ports();
}
self.normalize_export_hw_out_ports();
if !self.export_mp3_supported_for_current_settings() {
self.export_format_mp3 = false;
}
let transport = session.get("transport").ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidInput, "No 'transport' in session")
})?;
let parse_range = |key: &str| -> std::io::Result<Option<(usize, usize)>> {
let value = transport.get(key).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("No 'transport.{key}' in session"),
)
})?;
if value.is_null() {
return Ok(None);
}
let arr = value.as_array().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("'transport.{key}' is not an array or null"),
)
})?;
if arr.len() != 2 {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("'transport.{key}' must have exactly 2 items"),
));
}
let start = arr[0].as_u64().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("'transport.{key}[0]' is not an unsigned integer"),
)
})? as usize;
let end = arr[1].as_u64().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("'transport.{key}[1]' is not an unsigned integer"),
)
})? as usize;
if end <= start {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("'transport.{key}' must satisfy end > start"),
));
}
Ok(Some((start, end)))
};
let parse_enabled = |key: &str| -> std::io::Result<bool> {
transport.get(key).and_then(Value::as_bool).ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
format!("No boolean 'transport.{key}' in session"),
)
})
};
let loaded_loop_range = parse_range("loop_range_samples")?;
let loaded_loop_enabled = parse_enabled("loop_enabled")?;
let loaded_punch_range = parse_range("punch_range_samples")?;
let loaded_punch_enabled = parse_enabled("punch_enabled")?;
let loaded_sample_rate_hz = transport
.get("sample_rate_hz")
.and_then(Value::as_i64)
.map(|v| v.max(1) as i32);
let loaded_period_frames = transport
.get("period_frames")
.and_then(Value::as_u64)
.map(|v| v.max(1) as usize);
self.loop_range_samples = loaded_loop_range;
self.loop_enabled = loaded_loop_enabled;
self.punch_range_samples = loaded_punch_range;
self.punch_enabled = loaded_punch_enabled;
if let Some(v) = loaded_period_frames {
self.state.blocking_write().oss_period_frames = v;
}
restore_actions.push(Action::SetLoopRange(loaded_loop_range));
restore_actions.push(Action::SetLoopEnabled(loaded_loop_enabled));
restore_actions.push(Action::SetPunchRange(loaded_punch_range));
restore_actions.push(Action::SetPunchEnabled(loaded_punch_enabled));
if let Some(session_rate_hz) = loaded_sample_rate_hz {
let (hw_loaded, hw_rate_hz) = {
let st = self.state.blocking_read();
(st.hw_loaded, st.hw_sample_rate_hz.max(1))
};
if hw_loaded && hw_rate_hz != session_rate_hz {
warnings.push(format!(
"Session sample rate is {} Hz, hardware is {} Hz",
session_rate_hz, hw_rate_hz
));
}
}
let loaded_tempo = transport
.get("tempo")
.and_then(Value::as_f64)
.unwrap_or(120.0) as f32;
let loaded_num = transport
.get("time_signature_num")
.and_then(Value::as_u64)
.map(|n| n.clamp(1, 16) as u8)
.unwrap_or(4);
let loaded_denom = match transport
.get("time_signature_denom")
.and_then(Value::as_u64)
.unwrap_or(4)
{
2 => 2,
4 => 4,
8 => 8,
16 => 16,
_ => 4,
};
let mut loaded_tempo_points = transport
.get("tempo_points")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let sample = entry.get("sample")?.as_u64()? as usize;
let bpm = entry.get("bpm")?.as_f64()? as f32;
Some(crate::state::TempoPoint {
sample,
bpm: bpm.clamp(20.0, 300.0),
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
let mut loaded_time_signature_points = transport
.get("time_signature_points")
.and_then(Value::as_array)
.map(|arr| {
arr.iter()
.filter_map(|entry| {
let sample = entry.get("sample")?.as_u64()? as usize;
let numerator = entry.get("numerator")?.as_u64()?.clamp(1, 16) as u8;
let denominator = match entry.get("denominator")?.as_u64()? {
2 => 2,
4 => 4,
8 => 8,
16 => 16,
_ => return None,
};
Some(crate::state::TimeSignaturePoint {
sample,
numerator,
denominator,
})
})
.collect::<Vec<_>>()
})
.unwrap_or_default();
if loaded_tempo_points.is_empty() {
loaded_tempo_points.push(crate::state::TempoPoint {
sample: 0,
bpm: loaded_tempo,
});
}
if loaded_time_signature_points.is_empty() {
loaded_time_signature_points.push(crate::state::TimeSignaturePoint {
sample: 0,
numerator: loaded_num,
denominator: loaded_denom,
});
}
loaded_tempo_points.sort_unstable_by_key(|p| p.sample);
loaded_time_signature_points.sort_unstable_by_key(|p| p.sample);
if loaded_tempo_points
.first()
.is_some_and(|first| first.sample != 0)
{
loaded_tempo_points.insert(
0,
crate::state::TempoPoint {
sample: 0,
bpm: loaded_tempo,
},
);
}
if loaded_time_signature_points
.first()
.is_some_and(|first| first.sample != 0)
{
loaded_time_signature_points.insert(
0,
crate::state::TimeSignaturePoint {
sample: 0,
numerator: loaded_num,
denominator: loaded_denom,
},
);
}
self.tempo_input = format!("{:.2}", loaded_tempo);
self.time_signature_num_input = loaded_num.to_string();
self.time_signature_denom_input = loaded_denom.to_string();
self.last_sent_tempo_bpm = Some(loaded_tempo as f64);
self.last_sent_time_signature = Some((loaded_num as u16, loaded_denom as u16));
restore_actions.push(Action::SetTempo(loaded_tempo as f64));
restore_actions.push(Action::SetTimeSignature {
numerator: loaded_num as u16,
denominator: loaded_denom as u16,
});
restore_actions.push(Action::SetTempoMap {
tempo_points: loaded_tempo_points
.iter()
.map(|p| maolan_engine::message::TempoPoint {
sample: p.sample,
bpm: p.bpm as f64,
})
.collect(),
time_signature_points: loaded_time_signature_points
.iter()
.map(|p| maolan_engine::message::TimeSignaturePoint {
sample: p.sample,
numerator: p.numerator as u16,
denominator: p.denominator as u16,
})
.collect(),
});
{
let mut state = self.state.blocking_write();
state.tempo = loaded_tempo;
state.time_signature_num = loaded_num;
state.time_signature_denom = loaded_denom;
state.tempo_points = loaded_tempo_points;
state.time_signature_points = loaded_time_signature_points;
}
{
let mut state = self.state.blocking_write();
state.pending_track_positions.clear();
state.pending_track_heights.clear();
state.pending_track_folder_state.clear();
let tracks_width = session["ui"]["tracks_width"].as_f64().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"No 'ui.tracks_width' in session",
)
})?;
let live_view_tracks_width = session["ui"]["live_view_tracks_width"]
.as_f64()
.unwrap_or(tracks_width);
let live_view_left_split = session["ui"]["live_view_left_split"]
.as_f64()
.unwrap_or(0.5) as f32;
let mixer_height = session["ui"]["mixer_height"].as_f64().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"No 'ui.mixer_height' in session",
)
})?;
state.tracks_width = Length::Fixed(tracks_width as f32);
state.live_view_tracks_width = Length::Fixed(live_view_tracks_width as f32);
state.live_view_left_split = live_view_left_split;
state.mixer_height = Length::Fixed(mixer_height as f32);
}
self.zoom_visible_bars = session["ui"]["zoom_visible_bars"]
.as_f64()
.map(|zoom| {
(zoom as f32).clamp(
crate::gui::MIN_ZOOM_VISIBLE_BARS,
crate::gui::MAX_ZOOM_VISIBLE_BARS,
)
})
.unwrap_or(self.zoom_visible_bars);
if let Some(_mode) = session["ui"]["snap_mode"].as_str()
&& let Ok(mode) = serde_json::from_value::<crate::message::SnapMode>(
session["ui"]["snap_mode"].clone(),
)
{
self.snap_mode = mode;
}
if let Some(_mode) = session["ui"]["midi_snap_mode"].as_str()
&& let Ok(mode) = serde_json::from_value::<crate::message::SnapMode>(
session["ui"]["midi_snap_mode"].clone(),
)
{
self.midi_snap_mode = mode;
}
if let Some(modulators) = session.get("modulators") {
self.modulators =
serde_json::from_value::<Vec<crate::state::Modulator>>(modulators.clone())
.unwrap_or_default();
}
if let Some(arr) = session["tracks"].as_array() {
for track in arr {
let name = {
if let Some(value) = track["name"].as_str() {
value.to_string()
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No 'name' in track",
));
}
};
if let (Some(x), Some(y)) = (
track["position"]["x"].as_f64(),
track["position"]["y"].as_f64(),
) {
self.state
.blocking_write()
.pending_track_positions
.insert(name.clone(), Point::new(x as f32, y as f32));
}
if let Some(height) = track["height"].as_f64() {
self.state
.blocking_write()
.pending_track_heights
.insert(name.clone(), height as f32);
}
let audio_ins = {
if let Some(value) = track["audio"]["ins"].as_u64() {
value as usize
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No 'audio_ins' in track",
));
}
};
let midi_ins = {
if let Some(value) = track["midi"]["ins"].as_u64() {
value as usize
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No 'midi_ins' in track",
));
}
};
let audio_outs = {
if let Some(value) = track["audio"]["outs"].as_u64() {
value as usize
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No 'audio_outs' in track",
));
}
};
let midi_outs = {
if let Some(value) = track["midi"]["outs"].as_u64() {
value as usize
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"No 'midi_outs' in track",
));
}
};
let primary_audio_ins = track
.get("primary_audio_ins")
.and_then(|value| value.as_u64())
.map(|value| value as usize)
.unwrap_or(audio_ins);
let primary_audio_outs = track
.get("primary_audio_outs")
.and_then(|value| value.as_u64())
.map(|value| value as usize)
.unwrap_or(audio_outs);
let folder = track
.get("is_folder")
.and_then(|value| value.as_bool())
.unwrap_or(false);
restore_actions.push(Action::AddTrack {
name: name.clone(),
audio_ins: primary_audio_ins.min(audio_ins),
audio_outs: primary_audio_outs.min(audio_outs),
midi_ins,
midi_outs,
folder,
});
for _ in primary_audio_ins.min(audio_ins)..audio_ins {
restore_actions.push(Action::TrackAddAudioInput(name.clone()));
}
for _ in primary_audio_outs.min(audio_outs)..audio_outs {
restore_actions.push(Action::TrackAddAudioOutput(name.clone()));
}
if let Some(value) = track["level"].as_f64()
&& value.is_finite()
{
restore_actions.push(Action::TrackLevel(name.clone(), value as f32));
}
if let Some(value) = track["balance"].as_f64()
&& value.is_finite()
{
restore_actions.push(Action::TrackBalance(name.clone(), value as f32));
}
if let Some(value) = track["armed"].as_bool() {
if value {
restore_actions.push(Action::TrackToggleArm(name.clone()));
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'armed' is not boolean",
));
}
if let Some(value) = track["muted"].as_bool() {
if value {
restore_actions.push(Action::TrackToggleMute(name.clone()));
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'muted' is not boolean",
));
}
if let Some(value) = track["soloed"].as_bool() {
if value {
restore_actions.push(Action::TrackToggleSolo(name.clone()));
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'soloed' is not boolean",
));
}
{
let is_folder = track
.get("is_folder")
.and_then(Value::as_bool)
.unwrap_or(false);
let folder_open = track
.get("folder_open")
.and_then(Value::as_bool)
.unwrap_or(true);
let parent_track = track
.get("parent_track")
.and_then(Value::as_str)
.map(String::from);
self.state
.blocking_write()
.pending_track_folder_state
.insert(name.clone(), (is_folder, folder_open, parent_track.clone()));
if is_folder {
restore_actions.push(Action::TrackSetFolder {
track_name: name.clone(),
is_folder: true,
});
}
if let Some(ref parent) = parent_track {
restore_actions.push(Action::TrackSetParent {
track_name: name.clone(),
parent_name: Some(parent.clone()),
});
}
}
if track
.get("phase_inverted")
.and_then(Value::as_bool)
.unwrap_or(false)
{
restore_actions.push(Action::TrackTogglePhase(name.clone()));
}
if let Some(values) = track["input_monitor"].as_array() {
for (lane, value) in values.iter().enumerate() {
if value.as_bool().unwrap_or(false) {
restore_actions.push(Action::TrackToggleInputMonitor {
track_name: name.clone(),
lane,
});
}
}
} else if let Some(value) = track["input_monitor"].as_bool() {
if value {
restore_actions.push(Action::TrackToggleInputMonitor {
track_name: name.clone(),
lane: 0,
});
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'input_monitor' is not a boolean or array",
));
}
if let Some(values) = track["disk_monitor"].as_array() {
for (lane, value) in values.iter().enumerate() {
if !value.as_bool().unwrap_or(true) {
restore_actions.push(Action::TrackToggleDiskMonitor {
track_name: name.clone(),
lane,
});
}
}
} else if let Some(value) = track["disk_monitor"].as_bool() {
if !value {
restore_actions.push(Action::TrackToggleDiskMonitor {
track_name: name.clone(),
lane: 0,
});
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'disk_monitor' is not a boolean or array",
));
}
if let Some(values) = track["midi_input_monitor"].as_array() {
for (lane, value) in values.iter().enumerate() {
if value.as_bool().unwrap_or(false) {
restore_actions.push(Action::TrackToggleMidiInputMonitor {
track_name: name.clone(),
lane,
});
}
}
}
if let Some(values) = track["midi_disk_monitor"].as_array() {
for (lane, value) in values.iter().enumerate() {
if !value.as_bool().unwrap_or(true) {
restore_actions.push(Action::TrackToggleMidiDiskMonitor {
track_name: name.clone(),
lane,
});
}
}
}
if let Some(value) = track.get("midi_lane_channels")
&& let Ok(channels) = serde_json::from_value::<Vec<Option<u8>>>(value.clone())
{
for (lane, channel) in channels.into_iter().enumerate() {
restore_actions.push(Action::TrackSetMidiLaneChannel {
track_name: name.clone(),
lane,
channel,
});
}
}
if let Some(value) = track["midi"].get("editor_view_mode")
&& let Ok(mode) =
serde_json::from_value::<crate::message::MidiEditorViewMode>(value.clone())
{
self.pending_track_midi_editor_view_mode
.insert(name.clone(), mode);
}
if track["frozen"].as_bool().unwrap_or(false) {
frozen_tracks.push(name.clone());
}
if track["is_master"].as_bool().unwrap_or(false) {
restore_actions.push(Action::TrackToggleMaster(name.clone()));
}
if let Some(color_obj) = track["color"].as_object()
&& let (Some(r), Some(g), Some(b), Some(a)) = (
color_obj.get("r").and_then(|v| v.as_f64()),
color_obj.get("g").and_then(|v| v.as_f64()),
color_obj.get("b").and_then(|v| v.as_f64()),
color_obj.get("a").and_then(|v| v.as_f64()),
)
{
restore_actions.push(Action::TrackSetColor {
track_name: name.clone(),
color: Some(maolan_engine::message::TrackColor {
r: r as f32,
g: g as f32,
b: b as f32,
a: a as f32,
}),
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(track["midi_learn_volume"].clone())
&& binding.is_some()
{
restore_actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target: maolan_engine::message::TrackMidiLearnTarget::Volume,
binding,
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(track["midi_learn_balance"].clone())
&& binding.is_some()
{
restore_actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target: maolan_engine::message::TrackMidiLearnTarget::Balance,
binding,
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(track["midi_learn_mute"].clone())
&& binding.is_some()
{
restore_actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target: maolan_engine::message::TrackMidiLearnTarget::Mute,
binding,
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(track["midi_learn_solo"].clone())
&& binding.is_some()
{
restore_actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target: maolan_engine::message::TrackMidiLearnTarget::Solo,
binding,
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(track["midi_learn_arm"].clone())
&& binding.is_some()
{
restore_actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target: maolan_engine::message::TrackMidiLearnTarget::Arm,
binding,
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(track["midi_learn_input_monitor"].clone())
&& binding.is_some()
{
restore_actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target: maolan_engine::message::TrackMidiLearnTarget::InputMonitor,
binding,
});
}
if let Ok(binding) = serde_json::from_value::<
Option<maolan_engine::message::MidiLearnBinding>,
>(track["midi_learn_disk_monitor"].clone())
&& binding.is_some()
{
restore_actions.push(Action::TrackSetMidiLearnBinding {
track_name: name.clone(),
target: maolan_engine::message::TrackMidiLearnTarget::DiskMonitor,
binding,
});
}
let frozen_audio_backup: Vec<crate::state::AudioClip> =
serde_json::from_value(track["frozen_audio_backup"].clone())
.unwrap_or_default();
let frozen_midi_backup: Vec<crate::state::MIDIClip> =
serde_json::from_value(track["frozen_midi_backup"].clone()).unwrap_or_default();
let frozen_render_clip =
track["frozen_render_clip"].as_str().map(|s| s.to_string());
if !frozen_audio_backup.is_empty()
|| !frozen_midi_backup.is_empty()
|| frozen_render_clip.is_some()
{
self.pending_track_freeze_restore.insert(
name.clone(),
(frozen_audio_backup, frozen_midi_backup, frozen_render_clip),
);
}
if let Some(audio_clips) = track["audio"]["clips"].as_array() {
for clip in audio_clips {
let clip_name = clip["name"].as_str().unwrap_or("").to_string();
let start = clip["start"].as_u64().unwrap_or(0) as usize;
let length = clip["length"].as_u64().unwrap_or(0) as usize;
let offset = clip["offset"].as_u64().unwrap_or(0) as usize;
let input_channel = clip["input_channel"].as_u64().unwrap_or(0) as usize;
let muted = clip["muted"].as_bool().unwrap_or(false);
let peaks_file = clip["peaks_file"].as_str().map(|s| s.to_string());
let fade_enabled = clip["fade_enabled"].as_bool().unwrap_or(true);
let fade_in_samples =
clip["fade_in_samples"].as_u64().unwrap_or(240) as usize;
let fade_out_samples =
clip["fade_out_samples"].as_u64().unwrap_or(240) as usize;
if clip_name.trim().is_empty() {
warnings.push(format!(
"Track '{}' has an audio clip with empty name",
name
));
}
if length == 0 {
warnings.push(format!(
"Audio clip '{}' on track '{}' has zero length",
clip_name, name
));
}
if clip_name.to_ascii_lowercase().ends_with(".wav") {
let wav_path = session_root.join(&clip_name);
if !wav_path.exists() {
warnings.push(format!(
"Missing WAV file for clip '{}': {}",
clip_name,
wav_path.display()
));
} else if !wav_path.is_file() {
warnings.push(format!(
"WAV clip path is not a file '{}': {}",
clip_name,
wav_path.display()
));
}
if let Some(peaks_rel) = peaks_file.as_ref() {
let peaks_path = session_root.join(peaks_rel);
if peaks_path.exists() && peaks_path.is_file() {
let key = Self::audio_clip_key(
&name, &clip_name, start, length, offset,
);
self.pending_peak_file_loads.insert(key, peaks_path);
}
}
if let Some(saved_source_len) = clip
.get("source_length_samples")
.and_then(Value::as_u64)
.map(|v| v as usize)
&& saved_source_len > 0
{
let key =
Self::audio_clip_key(&name, &clip_name, start, length, offset);
self.pending_source_lengths.insert(key, saved_source_len);
}
}
if clip
.get("grouped_clips")
.and_then(serde_json::Value::as_array)
.is_some_and(|children| !children.is_empty())
&& let Ok(mut grouped_clip) =
serde_json::from_value::<crate::state::AudioClip>(clip.clone())
{
grouped_clip.ensure_id();
restore_actions.push(Action::AddGroupedClip {
track_name: name.clone(),
kind: Kind::Audio,
audio_clip: Some(Self::audio_clip_to_data(&grouped_clip)),
midi_clip: None,
});
continue;
}
let clip_id = clip["id"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(crate::state::generate_clip_id);
restore_actions.push(Action::AddClip {
clip_id,
name: clip_name,
track_name: name.clone(),
start,
length,
offset,
input_channel,
muted,
peaks_file: peaks_file.clone(),
kind: Kind::Audio,
fade_enabled,
fade_in_samples,
fade_out_samples,
source_name: clip["pitch_correction_source_name"]
.as_str()
.map(str::to_string),
source_offset: clip["pitch_correction_source_offset"]
.as_u64()
.map(|v| v as usize),
source_length: clip["pitch_correction_source_length"]
.as_u64()
.map(|v| v as usize),
preview_name: clip["pitch_correction_preview_name"]
.as_str()
.map(str::to_string),
pitch_correction_points: clip["pitch_correction_points"]
.as_array()
.map(|points| {
points
.iter()
.map(|point| {
maolan_engine::message::PitchCorrectionPointData {
start_sample: point["start_sample"]
.as_u64()
.unwrap_or(0)
as usize,
length_samples: point["length_samples"]
.as_u64()
.unwrap_or(0)
as usize,
detected_midi_pitch: point["detected_midi_pitch"]
.as_f64()
.unwrap_or(0.0)
as f32,
target_midi_pitch: point["target_midi_pitch"]
.as_f64()
.unwrap_or(0.0)
as f32,
clarity: point["clarity"].as_f64().unwrap_or(0.0)
as f32,
}
})
.collect()
})
.unwrap_or_default(),
pitch_correction_frame_likeness:
clip["pitch_correction_frame_likeness"]
.as_f64()
.map(|v| v as f32),
pitch_correction_inertia_ms: clip["pitch_correction_inertia_ms"]
.as_u64()
.map(|v| v as u16),
pitch_correction_formant_compensation:
clip["pitch_correction_formant_compensation"].as_bool(),
plugin_graph_json: clip
.get("plugin_graph_json")
.filter(|value| !value.is_null())
.cloned()
.or_else(|| {
Some(Self::default_clip_plugin_graph_json(
audio_ins, audio_outs,
))
}),
});
}
}
if let Some(midi_clips) = track["midi"]["clips"].as_array() {
for clip in midi_clips {
let clip_name = clip["name"].as_str().unwrap_or("").to_string();
let start = clip["start"].as_u64().unwrap_or(0) as usize;
let length = clip["length"].as_u64().unwrap_or(0) as usize;
let offset = clip["offset"].as_u64().unwrap_or(0) as usize;
let input_channel = clip["input_channel"].as_u64().unwrap_or(0) as usize;
let muted = clip["muted"].as_bool().unwrap_or(false);
if clip_name.trim().is_empty() {
warnings
.push(format!("Track '{}' has a MIDI clip with empty name", name));
}
if length == 0 {
warnings.push(format!(
"MIDI clip '{}' on track '{}' has zero length",
clip_name, name
));
}
if clip_name.to_ascii_lowercase().ends_with(".mid")
|| clip_name.to_ascii_lowercase().ends_with(".midi")
{
let mid_path = session_root.join(&clip_name);
if !mid_path.exists() {
warnings.push(format!(
"Missing MIDI file for clip '{}': {}",
clip_name,
mid_path.display()
));
} else if !mid_path.is_file() {
warnings.push(format!(
"MIDI clip path is not a file '{}': {}",
clip_name,
mid_path.display()
));
}
}
if clip
.get("grouped_clips")
.and_then(serde_json::Value::as_array)
.is_some_and(|children| !children.is_empty())
&& let Ok(mut grouped_clip) =
serde_json::from_value::<crate::state::MIDIClip>(clip.clone())
{
grouped_clip.ensure_id();
restore_actions.push(Action::AddGroupedClip {
track_name: name.clone(),
kind: Kind::MIDI,
audio_clip: None,
midi_clip: Some(Self::midi_clip_to_data(&grouped_clip)),
});
continue;
}
let clip_id = clip["id"]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_string)
.unwrap_or_else(crate::state::generate_clip_id);
restore_actions.push(Action::AddClip {
clip_id,
name: clip_name,
track_name: name.clone(),
start,
length,
offset,
input_channel,
muted,
peaks_file: None,
kind: Kind::MIDI,
fade_enabled: true,
fade_in_samples: 240,
fade_out_samples: 240,
source_name: None,
source_offset: None,
source_length: None,
preview_name: None,
pitch_correction_points: vec![],
pitch_correction_frame_likeness: None,
pitch_correction_inertia_ms: None,
pitch_correction_formant_compensation: None,
plugin_graph_json: None,
});
}
}
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'tracks' is not an array",
));
}
if let Some(connections_value) = session.get("connections") {
match serde_json::from_value::<Vec<Connection>>(connections_value.clone()) {
Ok(saved_connections) => {
for conn in saved_connections {
restore_actions.push(Action::Connect {
from_track: conn.from_track,
from_port: conn.from_port,
to_track: conn.to_track,
to_port: conn.to_port,
kind: conn.kind,
});
}
}
Err(e) => {
warnings.push(format!("Failed to parse 'connections': {e}"));
}
}
}
{
let (vst3_plugins, clap_plugins) = {
let state = self.state.blocking_read();
(state.vst3_plugins.clone(), state.clap_plugins.clone())
};
if let Some(graphs) = session["graphs"].as_object() {
for (track_name, graph_v) in graphs {
restore_actions.push(Action::TrackClearDefaultPassthrough {
track_name: track_name.clone(),
});
let Some(plugins_arr) = graph_v["plugins"].as_array() else {
continue;
};
let mut runtime_nodes = Vec::new();
let mut next_instance_id = 0usize;
for p in plugins_arr {
let Some(uri) = p["uri"].as_str() else {
continue;
};
match p.get("format").and_then(Value::as_str) {
Some("LV2") => {
#[cfg(all(unix, not(target_os = "macos")))]
{
let instance_id = next_instance_id;
next_instance_id += 1;
runtime_nodes.push(
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(
instance_id,
),
);
restore_actions.push(Action::TrackLoadLv2Plugin {
track_name: track_name.clone(),
plugin_uri: uri.to_string(),
instance_id: Some(instance_id),
});
if let Some(state) = Self::lv2_state_from_json(&p["state"]) {
restore_actions.push(Action::TrackSetLv2PluginState {
track_name: track_name.clone(),
instance_id,
state,
});
}
}
}
Some("VST3") => {
let instance_id = next_instance_id;
next_instance_id += 1;
runtime_nodes.push(
maolan_engine::message::PluginGraphNode::Vst3PluginInstance(
instance_id,
),
);
if let Some(plugin_path) =
Self::resolve_vst3_plugin_path(uri, &vst3_plugins)
{
restore_actions.push(Action::TrackLoadVst3Plugin {
track_name: track_name.clone(),
plugin_path,
instance_id: Some(instance_id),
});
} else {
warnings.push(format!(
"VST3 plugin '{}' not found in registry (track '{}')",
uri, track_name
));
}
}
Some("CLAP") => {
let instance_id = next_instance_id;
next_instance_id += 1;
runtime_nodes.push(
maolan_engine::message::PluginGraphNode::ClapPluginInstance(
instance_id,
),
);
let resolved = Self::resolve_clap_plugin_path(uri, &clap_plugins);
if let Some(plugin_path) = resolved {
restore_actions.push(Action::TrackLoadClapPlugin {
track_name: track_name.clone(),
plugin_path,
instance_id: Some(instance_id),
});
if let Some(state) = Self::clap_state_from_json_resolved(
&p["state"],
&session_root,
) {
tracing::info!(%track_name, instance_id, state_len = state.bytes.len(), "load: restoring CLAP plugin state");
restore_actions.push(Action::TrackClapRestoreState {
track_name: track_name.clone(),
instance_id,
state,
});
} else {
tracing::info!(%track_name, instance_id, "load: no CLAP plugin state to restore");
}
} else {
warnings.push(format!(
"CLAP plugin '{}' not found in registry (track '{}')",
uri, track_name
));
}
}
_ => {}
}
}
if let Some(connections_arr) = graph_v["connections"].as_array() {
for c in connections_arr {
let Some(from_node) = Self::plugin_node_from_json_with_runtime_nodes(
&c["from_node"],
&runtime_nodes,
) else {
continue;
};
let Some(to_node) = Self::plugin_node_from_json_with_runtime_nodes(
&c["to_node"],
&runtime_nodes,
) else {
continue;
};
let Some(kind) = Self::kind_from_json(&c["kind"]) else {
continue;
};
let from_port = c["from_port"].as_u64().unwrap_or(0) as usize;
let to_port = c["to_port"].as_u64().unwrap_or(0) as usize;
match kind {
Kind::Audio => {
restore_actions.push(Action::TrackConnectPluginAudio {
track_name: track_name.clone(),
from_node,
from_port,
to_node,
to_port,
})
}
Kind::MIDI => {
restore_actions.push(Action::TrackConnectPluginMidi {
track_name: track_name.clone(),
from_node,
from_port,
to_node,
to_port,
})
}
}
}
}
if let Some(positions) = graph_v["plugin_positions"].as_object() {
let index_to_id: std::collections::HashMap<usize, usize> =
runtime_nodes
.iter()
.enumerate()
.filter_map(|(idx, node)| match node {
maolan_engine::message::PluginGraphNode::ClapPluginInstance(
id,
)
| maolan_engine::message::PluginGraphNode::Vst3PluginInstance(
id,
) => Some((idx, *id)),
#[cfg(all(unix, not(target_os = "macos")))]
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(
id,
) => Some((idx, *id)),
_ => None,
})
.collect();
for (idx_str, pos_v) in positions {
if let (Some(idx), Some(x), Some(y)) = (
idx_str.parse::<usize>().ok(),
pos_v["x"].as_f64(),
pos_v["y"].as_f64(),
) && let Some(&instance_id) = index_to_id.get(&idx)
{
self.state
.blocking_write()
.plugin_graph_plugin_positions
.entry(track_name.clone())
.or_default()
.insert(instance_id, Point::new(x as f32, y as f32));
}
}
}
if let Some(connectable_arr) = graph_v["connectable_connections"].as_array() {
let index_to_id: std::collections::HashMap<usize, usize> =
runtime_nodes
.iter()
.enumerate()
.filter_map(|(idx, node)| match node {
maolan_engine::message::PluginGraphNode::ClapPluginInstance(
id,
)
| maolan_engine::message::PluginGraphNode::Vst3PluginInstance(
id,
) => Some((idx, *id)),
#[cfg(all(unix, not(target_os = "macos")))]
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(
id,
) => Some((idx, *id)),
_ => None,
})
.collect();
for conn in Self::connectable_connections_from_json(
&Value::Array(connectable_arr.clone()),
&index_to_id,
) {
match conn.kind {
Kind::Audio => restore_actions.push(Action::TrackConnectAudio {
track_name: track_name.clone(),
from: conn.from,
from_port: conn.from_port,
to: conn.to,
to_port: conn.to_port,
}),
Kind::MIDI => restore_actions.push(Action::TrackConnectMidi {
track_name: track_name.clone(),
from: conn.from,
from_port: conn.from_port,
to: conn.to,
to_port: conn.to_port,
}),
}
}
}
Self::apply_disabled_default_connections(
track_name,
graph_v,
&mut restore_actions,
);
}
}
}
{
let mut state = self.state.blocking_write();
state.selected_slots.clear();
state.selected_scene = None;
state.slot_runtimes.clear();
let track_names: Vec<String> = state.tracks.iter().map(|t| t.name.clone()).collect();
for track_name in track_names {
state.session.ensure_track_slots(&track_name);
}
}
{
let state = self.state.blocking_read();
for (track_name, slots) in &state.session.slots {
for (scene_index, slot) in slots.iter().enumerate() {
if let Some(clip_ref) = slot.clip.as_ref() {
restore_actions.push(Action::TrackSetSessionSlot {
track_name: track_name.clone(),
scene_index,
clip_id: Some(clip_ref.clip_id.clone()),
});
restore_actions.push(Action::TrackSetSessionSlotPlayEnabled {
track_name: track_name.clone(),
scene_index,
enabled: slot.is_play_enabled(),
});
}
}
}
}
if warnings.is_empty() {
self.state.blocking_write().message = "Session loaded".to_string();
} else {
let shown = warnings.len().min(8);
let mut msg = format!("Session loaded with {} warning(s):", warnings.len());
for warning in warnings.iter().take(shown) {
msg.push_str("\n- ");
msg.push_str(warning);
}
if warnings.len() > shown {
msg.push_str(&format!("\n- ... and {} more", warnings.len() - shown));
}
self.state.blocking_write().message = msg;
}
for track_name in frozen_tracks {
restore_actions.push(Action::TrackSetFrozen {
track_name,
frozen: true,
});
}
let engine_modulators: Vec<maolan_engine::modulator::Modulator> =
self.modulators.iter().map(Into::into).collect();
restore_actions.push(Action::SetModulators(engine_modulators));
restore_actions.push(Action::EndSessionRestore);
tracing::info!(
path = %path,
elapsed_ms = load_start.elapsed().as_millis(),
actions = restore_actions.len(),
"session load prepared restore actions"
);
Ok(Self::restore_actions_task(restore_actions))
}
pub(super) fn refresh_graphs_then_save_template(&mut self, path: String) -> Task<Message> {
let track_names: Vec<String> = self
.state
.blocking_read()
.tracks
.iter()
.map(|t| t.name.clone())
.collect();
self.pending_save_path = Some(path);
self.pending_save_tracks = track_names.iter().cloned().collect();
self.pending_save_clap_tracks = track_names.iter().cloned().collect();
let clip_targets = self.clip_clap_snapshot_targets(&track_names);
self.pending_save_clap_clips = clip_targets.iter().cloned().collect();
self.pending_save_is_template = true;
if self.pending_save_tracks.is_empty() {
let Some(path) = self.pending_save_path.take() else {
return Task::none();
};
if let Err(e) = self.save_template(path.clone()) {
self.state.blocking_write().message = format!("Failed to save template: {}", e);
return Task::none();
}
self.state.blocking_write().message = "Template saved".to_string();
return Task::none();
}
let tasks = track_names
.into_iter()
.flat_map(|track_name| {
vec![
self.send(Action::TrackGetPluginGraph {
track_name: track_name.clone(),
include_state: true,
}),
self.send(Action::TrackSnapshotAllClapStates { track_name }),
]
})
.chain(
clip_targets
.into_iter()
.map(|(track_name, clip_idx, instance_id)| {
self.send(Action::ClipClapSnapshotState {
track_name,
clip_idx,
instance_id,
})
}),
)
.collect::<Vec<_>>();
Task::batch(tasks)
}
pub(super) fn refresh_graphs_then_save(&mut self, path: String) -> Task<Message> {
let track_names: Vec<String> = self
.state
.blocking_read()
.tracks
.iter()
.map(|t| t.name.clone())
.collect();
self.pending_save_path = Some(path);
self.pending_save_tracks = track_names.iter().cloned().collect();
self.pending_save_clap_tracks = track_names.iter().cloned().collect();
let clip_targets = self.clip_clap_snapshot_targets(&track_names);
for (_tn, _ci, _id) in &clip_targets {}
self.pending_save_clap_clips = clip_targets.iter().cloned().collect();
self.pending_save_is_template = false;
if self.pending_save_tracks.is_empty() {
let Some(path) = self.pending_save_path.take() else {
return Task::none();
};
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);
return Task::none();
}
return self.send(Action::SetSessionPath(path));
}
tracing::info!(
track_count = track_names.len(),
clip_count = clip_targets.len(),
"save: requesting CLAP state snapshots"
);
let tasks = track_names
.into_iter()
.flat_map(|track_name| {
vec![
self.send(Action::TrackGetPluginGraph {
track_name: track_name.clone(),
include_state: true,
}),
self.send(Action::TrackSnapshotAllClapStates { track_name }),
]
})
.chain(
clip_targets
.into_iter()
.map(|(track_name, clip_idx, instance_id)| {
self.send(Action::ClipClapSnapshotState {
track_name,
clip_idx,
instance_id,
})
}),
)
.collect::<Vec<_>>();
Task::batch(tasks)
}
pub(super) fn apply_track_template(
&mut self,
track_name: String,
template_name: String,
) -> Task<Message> {
use std::fs::File;
use std::io::BufReader;
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let template_path = format!(
"{}/.config/maolan/track_templates/{}/track.json",
home, template_name
);
let file = match File::open(&template_path) {
Ok(f) => f,
Err(e) => {
return Task::done(Message::Response(Err(format!(
"Failed to open template: {}",
e
))));
}
};
let reader = BufReader::new(file);
let json: serde_json::Value = match serde_json::from_reader(reader) {
Ok(j) => j,
Err(e) => {
return Task::done(Message::Response(Err(format!(
"Failed to parse template: {}",
e
))));
}
};
let track = {
let state = self.state.blocking_read();
state.tracks.iter().find(|t| t.name == track_name).cloned()
};
let Some(track) = track else {
return Task::done(Message::Response(Err(format!(
"Track '{}' not found",
track_name
))));
};
let audio = json.get("track").and_then(|t| t.get("audio"));
let midi = json.get("track").and_then(|t| t.get("midi"));
let t_audio_ins = audio
.and_then(|a| a.get("ins"))
.and_then(Value::as_u64)
.unwrap_or(1) as usize;
let t_audio_outs = audio
.and_then(|a| a.get("outs"))
.and_then(Value::as_u64)
.unwrap_or(1) as usize;
let t_midi_ins = midi
.and_then(|m| m.get("ins"))
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let t_midi_outs = midi
.and_then(|m| m.get("outs"))
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
if track.primary_audio_ins() != t_audio_ins
|| track.primary_audio_outs() != t_audio_outs
|| track.midi.ins != t_midi_ins
|| track.midi.outs != t_midi_outs
{
return Task::done(Message::Response(Err(format!(
"Track '{}' input/output counts do not match template",
track_name
))));
}
let clear = self.send(Action::TrackClearPlugins {
track_name: track_name.clone(),
});
let load = self.load_track_template(track_name, template_name);
clear.chain(load)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::{
fs,
sync::{LazyLock, Mutex},
time::{SystemTime, UNIX_EPOCH},
};
static ENV_GUARD: LazyLock<Mutex<()>> = LazyLock::new(|| Mutex::new(()));
#[test]
fn export_render_mode_to_json_mixdown() {
let result = Maolan::export_render_mode_to_json(crate::message::ExportRenderMode::Mixdown);
assert_eq!(result, json!("mixdown"));
}
#[test]
fn export_render_mode_to_json_stems_post_fader() {
let result =
Maolan::export_render_mode_to_json(crate::message::ExportRenderMode::StemsPostFader);
assert_eq!(result, json!("stems_post_fader"));
}
#[test]
fn export_render_mode_to_json_stems_pre_fader() {
let result =
Maolan::export_render_mode_to_json(crate::message::ExportRenderMode::StemsPreFader);
assert_eq!(result, json!("stems_pre_fader"));
}
#[test]
fn export_render_mode_from_json_mixdown() {
let result = Maolan::export_render_mode_from_json(Some(&json!("mixdown")));
assert!(matches!(result, crate::message::ExportRenderMode::Mixdown));
}
#[test]
fn export_render_mode_from_json_stems_post_fader() {
let result = Maolan::export_render_mode_from_json(Some(&json!("stems_post_fader")));
assert!(matches!(
result,
crate::message::ExportRenderMode::StemsPostFader
));
}
#[test]
fn export_render_mode_from_json_stems_pre_fader() {
let result = Maolan::export_render_mode_from_json(Some(&json!("stems_pre_fader")));
assert!(matches!(
result,
crate::message::ExportRenderMode::StemsPreFader
));
}
#[test]
fn export_render_mode_from_json_defaults_to_mixdown() {
let result = Maolan::export_render_mode_from_json(Some(&json!("unknown")));
assert!(matches!(result, crate::message::ExportRenderMode::Mixdown));
let result = Maolan::export_render_mode_from_json(None);
assert!(matches!(result, crate::message::ExportRenderMode::Mixdown));
}
#[test]
fn export_bit_depth_to_json_int16() {
let result = Maolan::export_bit_depth_to_json(crate::message::ExportBitDepth::Int16);
assert_eq!(result, json!("int16"));
}
#[test]
fn export_bit_depth_to_json_int24() {
let result = Maolan::export_bit_depth_to_json(crate::message::ExportBitDepth::Int24);
assert_eq!(result, json!("int24"));
}
#[test]
fn export_bit_depth_to_json_int32() {
let result = Maolan::export_bit_depth_to_json(crate::message::ExportBitDepth::Int32);
assert_eq!(result, json!("int32"));
}
#[test]
fn export_bit_depth_to_json_float32() {
let result = Maolan::export_bit_depth_to_json(crate::message::ExportBitDepth::Float32);
assert_eq!(result, json!("float32"));
}
#[test]
fn export_bit_depth_from_json_int16() {
let result = Maolan::export_bit_depth_from_json(Some(&json!("int16")));
assert!(matches!(result, crate::message::ExportBitDepth::Int16));
}
#[test]
fn export_bit_depth_from_json_int32() {
let result = Maolan::export_bit_depth_from_json(Some(&json!("int32")));
assert!(matches!(result, crate::message::ExportBitDepth::Int32));
}
#[test]
fn export_bit_depth_from_json_float32() {
let result = Maolan::export_bit_depth_from_json(Some(&json!("float32")));
assert!(matches!(result, crate::message::ExportBitDepth::Float32));
}
#[test]
fn export_bit_depth_from_json_defaults_to_int24() {
let result = Maolan::export_bit_depth_from_json(Some(&json!("unknown")));
assert!(matches!(result, crate::message::ExportBitDepth::Int24));
let result = Maolan::export_bit_depth_from_json(None);
assert!(matches!(result, crate::message::ExportBitDepth::Int24));
}
#[test]
fn export_mp3_mode_to_json_cbr() {
let result = Maolan::export_mp3_mode_to_json(crate::message::ExportMp3Mode::Cbr);
assert_eq!(result, json!("cbr"));
}
#[test]
fn export_mp3_mode_to_json_vbr() {
let result = Maolan::export_mp3_mode_to_json(crate::message::ExportMp3Mode::Vbr);
assert_eq!(result, json!("vbr"));
}
#[test]
fn export_mp3_mode_from_json_cbr() {
let result = Maolan::export_mp3_mode_from_json(Some(&json!("cbr")));
assert!(matches!(result, crate::message::ExportMp3Mode::Cbr));
}
#[test]
fn export_mp3_mode_from_json_vbr() {
let result = Maolan::export_mp3_mode_from_json(Some(&json!("vbr")));
assert!(matches!(result, crate::message::ExportMp3Mode::Vbr));
}
#[test]
fn export_mp3_mode_from_json_defaults_to_cbr() {
let result = Maolan::export_mp3_mode_from_json(Some(&json!("unknown")));
assert!(matches!(result, crate::message::ExportMp3Mode::Cbr));
let result = Maolan::export_mp3_mode_from_json(None);
assert!(matches!(result, crate::message::ExportMp3Mode::Cbr));
}
#[test]
fn export_normalize_mode_to_json_peak() {
let result =
Maolan::export_normalize_mode_to_json(crate::message::ExportNormalizeMode::Peak);
assert_eq!(result, json!("peak"));
}
#[test]
fn export_normalize_mode_to_json_loudness() {
let result =
Maolan::export_normalize_mode_to_json(crate::message::ExportNormalizeMode::Loudness);
assert_eq!(result, json!("loudness"));
}
#[test]
fn export_normalize_mode_from_json_peak() {
let result = Maolan::export_normalize_mode_from_json(Some(&json!("peak")));
assert!(matches!(result, crate::message::ExportNormalizeMode::Peak));
}
#[test]
fn export_normalize_mode_from_json_loudness() {
let result = Maolan::export_normalize_mode_from_json(Some(&json!("loudness")));
assert!(matches!(
result,
crate::message::ExportNormalizeMode::Loudness
));
}
#[test]
fn export_normalize_mode_from_json_defaults_to_peak() {
let result = Maolan::export_normalize_mode_from_json(Some(&json!("unknown")));
assert!(matches!(result, crate::message::ExportNormalizeMode::Peak));
let result = Maolan::export_normalize_mode_from_json(None);
assert!(matches!(result, crate::message::ExportNormalizeMode::Peak));
}
#[test]
fn kind_to_json_audio() {
assert_eq!(Maolan::kind_to_json(Kind::Audio), json!("audio"));
}
#[test]
fn kind_to_json_midi() {
assert_eq!(Maolan::kind_to_json(Kind::MIDI), json!("midi"));
}
#[test]
fn kind_from_json_audio() {
let result = Maolan::kind_from_json(&json!("audio"));
assert_eq!(result, Some(Kind::Audio));
}
#[test]
fn kind_from_json_midi() {
let result = Maolan::kind_from_json(&json!("midi"));
assert_eq!(result, Some(Kind::MIDI));
}
#[test]
fn kind_from_json_unknown() {
let result = Maolan::kind_from_json(&json!("unknown"));
assert_eq!(result, None);
}
#[test]
fn save_track_as_template_writes_plugin_graph() {
let _guard = ENV_GUARD.lock().expect("lock env guard");
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let temp_home = std::env::temp_dir().join(format!("maolan_track_template_save_{unique}"));
let template_dir = 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();
state.tracks.push(crate::state::Track::new(
"Drums".to_string(),
0.0,
2,
2,
0,
0,
));
let plugin = maolan_engine::message::PluginGraphPlugin {
node: maolan_engine::message::PluginGraphNode::ClapPluginInstance(0),
instance_id: 0,
format: "CLAP".to_string(),
uri: "/fake/DrumMachine.clap".to_string(),
plugin_id: "com.example.drummachine".to_string(),
name: "Fake Drum Machine".to_string(),
main_audio_inputs: 2,
main_audio_outputs: 2,
audio_inputs: 2,
audio_outputs: 2,
midi_inputs: 0,
midi_outputs: 0,
state: None,
bypassed: false,
};
state.plugin_graphs_by_track.insert(
"Drums".to_string(),
(
vec![plugin],
vec![maolan_engine::message::PluginGraphConnection {
from_node: maolan_engine::message::PluginGraphNode::TrackInput,
from_port: 0,
to_node: maolan_engine::message::PluginGraphNode::ClapPluginInstance(0),
to_port: 0,
kind: Kind::Audio,
}],
),
);
}
let _ = app.save_track_as_template("Drums", template_dir.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_dir.join("track.json");
assert!(track_json_path.exists(), "track template file should exist");
let metadata = track_json_path.metadata().expect("file metadata");
assert!(
metadata.len() > 0,
"track template file should not be empty"
);
let saved: serde_json::Value =
serde_json::from_reader(File::open(&track_json_path).expect("open track template"))
.expect("parse track template");
let plugins = saved["graph"]["plugins"].as_array().expect("plugins array");
assert_eq!(plugins.len(), 1);
assert_eq!(plugins[0]["format"], "CLAP");
assert_eq!(plugins[0]["uri"], "com.example.drummachine");
let connections = saved["graph"]["connections"]
.as_array()
.expect("connections array");
assert_eq!(connections.len(), 1);
let _ = fs::remove_dir_all(&temp_home);
}
#[test]
fn build_track_template_actions_loads_plugins_and_connections() {
let app = Maolan::default();
let json = json!({
"track": {"name": "Drums", "audio": {"ins": 2, "outs": 2}, "midi": {"ins": 0, "outs": 0}},
"graph": {
"plugins": [
{"format": "VST3", "uri": "/fake/plugin.vst3", "state": null}
],
"connections": [
{"from_node": {"type": "track_input"}, "from_port": 0, "to_node": {"type": "vst3_plugin", "plugin_index": 0}, "to_port": 0, "kind": "audio"}
]
}
});
let actions = app.build_track_template_actions("MyDrums", &json);
assert!(actions.iter().any(|a| matches!(
a,
Action::TrackLoadVst3Plugin {
track_name,
plugin_path,
instance_id: Some(0),
..
} if track_name == "MyDrums" && plugin_path == "/fake/plugin.vst3"
)));
assert!(actions.iter().any(|a| matches!(
a,
Action::TrackConnectPluginAudio {
track_name,
from_node,
to_node,
..
} if track_name == "MyDrums"
&& matches!(from_node, maolan_engine::message::PluginGraphNode::TrackInput)
&& matches!(to_node, maolan_engine::message::PluginGraphNode::Vst3PluginInstance(0))
)));
}
#[test]
fn build_track_template_actions_resolves_clap_plugin_id_to_path() {
let app = Maolan::default();
{
let mut state = app.state.blocking_write();
state
.clap_plugins
.push(maolan_engine::clap::ClapPluginInfo {
path: "/usr/lib/clap/DrumMachine.clap::com.example.drummachine".to_string(),
name: "Drum Machine".to_string(),
capabilities: None,
});
}
let json = json!({
"track": {"name": "Drums", "audio": {"ins": 2, "outs": 2}, "midi": {"ins": 0, "outs": 0}},
"graph": {
"plugins": [
{"format": "CLAP", "uri": "com.example.drummachine", "state": null}
],
"connections": []
}
});
let actions = app.build_track_template_actions("MyDrums", &json);
assert!(actions.iter().any(|a| matches!(
a,
Action::TrackLoadClapPlugin {
track_name,
plugin_path,
instance_id: Some(0),
..
} if track_name == "MyDrums" && plugin_path == "/usr/lib/clap/DrumMachine.clap::com.example.drummachine"
)));
}
#[test]
fn disabled_default_connections_json_detects_missing_child_outputs() {
let mut folder = crate::state::Track::new("Folder".to_string(), 0.0, 2, 2, 2, 2);
folder.is_folder = true;
let mut child = crate::state::Track::new("Child".to_string(), 0.0, 2, 2, 2, 2);
child.parent_track = Some("Folder".to_string());
let tracks = vec![folder.clone(), child];
let actual = vec![
ConnectableConnection {
from: ConnectableRef::ChildTrack("Child".to_string()),
from_port: 0,
to: ConnectableRef::TrackOutput,
to_port: 0,
kind: Kind::Audio,
},
ConnectableConnection {
from: ConnectableRef::ChildTrack("Child".to_string()),
from_port: 0,
to: ConnectableRef::TrackOutput,
to_port: 2,
kind: Kind::MIDI,
},
];
let disabled = Maolan::disabled_default_connections_json(&folder, &tracks, &actual);
assert_eq!(disabled.len(), 2);
assert!(disabled.iter().any(|d| {
d["child"] == "Child"
&& d["from_port"] == 1
&& d["to_port"] == 1
&& d["kind"] == "audio"
}));
assert!(disabled.iter().any(|d| {
d["child"] == "Child" && d["from_port"] == 1 && d["to_port"] == 3 && d["kind"] == "midi"
}));
}
#[test]
fn apply_disabled_default_connections_generates_disconnect_actions() {
let graph = json!({
"disabled_default_connections": [
{"child": "Child", "from_port": 0, "to_port": 0, "kind": "audio"},
{"child": "Child", "from_port": 1, "to_port": 3, "kind": "midi"}
]
});
let mut actions = Vec::new();
Maolan::apply_disabled_default_connections("Folder", &graph, &mut actions);
assert_eq!(actions.len(), 2);
assert!(matches!(
actions[0],
Action::TrackDisconnectAudio {
ref track_name,
ref from,
from_port,
ref to,
to_port,
} if track_name == "Folder"
&& from == &ConnectableRef::ChildTrack("Child".to_string())
&& from_port == 0
&& to == &ConnectableRef::TrackOutput
&& to_port == 0
));
assert!(matches!(
actions[1],
Action::TrackDisconnectMidi {
ref track_name,
ref from,
from_port,
ref to,
to_port,
} if track_name == "Folder"
&& from == &ConnectableRef::ChildTrack("Child".to_string())
&& from_port == 1
&& to == &ConnectableRef::TrackOutput
&& to_port == 3
));
}
}
#[test]
fn midi_editor_view_mode_roundtrips() {
let mode = crate::message::MidiEditorViewMode::DrumGrid;
let json = serde_json::to_value(mode).unwrap();
assert_eq!(json, serde_json::json!("drumgrid"));
let back: crate::message::MidiEditorViewMode = serde_json::from_value(json).unwrap();
assert!(matches!(back, crate::message::MidiEditorViewMode::DrumGrid));
}