use super::{CLIENT, Maolan};
use crate::{
message::Message,
state::{Connection, ConnectionViewSelection},
};
use iced::{Length, Point, Task};
use maolan_engine::{
kind::Kind,
message::{Action, GlobalMidiLearnTarget, Message as EngineMessage},
};
use serde_json::{Value, json};
use std::{
fs::{self, File},
io::{self, BufReader, BufWriter},
path::{Path, PathBuf},
};
use tracing::error;
impl Maolan {
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,
)
}
#[cfg(all(unix, not(target_os = "macos")))]
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(),
}
}
#[cfg(all(unix, not(target_os = "macos")))]
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
}
pub(super) fn save_template(&self, path: String) -> std::io::Result<()> {
use tracing::info;
info!("Saving template to: {}", path);
let filename = "session.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"))?;
info!("Created template directories in: {}", path);
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 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);
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
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);
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();
graphs.insert(
track_name.clone(),
json!({
"plugins": plugins_json,
"connections": conns_json,
}),
);
}
Value::Object(graphs)
};
#[cfg(not(all(unix, not(target_os = "macos"))))]
let graphs = Value::Object(serde_json::Map::new());
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,
"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,
"realtime_frames": state.oss_realtime_frames,
"low_watermark_frames": state.oss_low_watermark_frames,
"hybrid_buffer_enabled": state.oss_hybrid_buffer_enabled,
"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,
"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,
}
});
serde_json::to_writer_pretty(file, &result)?;
info!("Template saved successfully to: {}", path);
Ok(())
}
pub(super) fn refresh_graph_then_save_track_template(
&mut self,
track_name: String,
path: String,
) -> Task<Message> {
#[cfg(all(unix, not(target_os = "macos")))]
{
self.pending_save_path = Some(path.clone());
self.pending_save_tracks = std::iter::once(track_name.clone()).collect();
self.pending_save_clap_tracks = std::iter::once(track_name.clone()).collect();
self.pending_save_is_template = false;
let tasks = vec![
self.send(Action::TrackGetPluginGraph {
track_name: track_name.clone(),
}),
self.send(Action::TrackSnapshotAllClapStates {
track_name: track_name.clone(),
}),
];
self.state.blocking_write().message =
format!("Saving track template for {}", track_name);
Task::batch(tasks)
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
self.save_track_as_template(&track_name, path)
}
}
pub(super) fn load_track_template(
&mut self,
track_name: String,
template_name: String,
) -> Task<Message> {
use std::fs::File;
use std::io::BufReader;
use tracing::info;
info!(
"Loading track template '{}' for track '{}'",
template_name, track_name
);
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;
}
}
#[cfg(all(unix, not(target_os = "macos")))]
let mut restore_actions = vec![];
#[cfg(not(all(unix, not(target_os = "macos"))))]
let restore_actions = vec![];
#[cfg(all(unix, not(target_os = "macos")))]
{
let clap_plugins = self.state.blocking_read().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_lv2_instance_id = 0usize;
let mut next_clap_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) {
Some("LV2") => {
let instance_id = next_lv2_instance_id;
next_lv2_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) =
plugin.get("state").and_then(Self::lv2_state_from_json)
{
restore_actions.push(Action::TrackSetLv2PluginState {
track_name: track_name.clone(),
instance_id,
state,
});
}
}
Some("CLAP") => {
let instance_id = next_clap_instance_id;
next_clap_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.clone(),
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.clone(),
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.clone(),
from_node,
from_port,
to_node,
to_port,
},
Kind::MIDI => Action::TrackConnectPluginMidi {
track_name: track_name.clone(),
from_node,
from_port,
to_node,
to_port,
},
});
}
}
}
}
if restore_actions.is_empty() {
Task::done(Message::None)
} else {
Self::restore_actions_task(restore_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;
use tracing::info;
info!(
"Copying track '{}' from branch '{}' into '{}'",
source_track_name, source_branch, self.session_branch
);
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,
))
}
pub(super) fn save_track_as_template(&self, track_name: &str, path: String) -> Task<Message> {
use tracing::info;
let result = (|| -> std::io::Result<()> {
info!("Saving track template to: {}", path);
let template_root = PathBuf::from(&path);
fs::create_dir_all(&path)?;
fs::create_dir_all(template_root.join("plugins"))?;
info!("Created track template directories in: {}", path);
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 track_json = serde_json::to_value(track).map_err(io::Error::other)?;
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 = {
#[cfg(all(unix, not(target_os = "macos")))]
{
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();
json!({
"plugins": plugins_json,
"connections": conns_json,
})
} else {
Value::Null
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
Value::Null
}
};
let track_connections: Vec<&crate::state::Connection> = state
.connections
.iter()
.filter(|c| c.from_track == track_name || c.to_track == track_name)
.collect();
let result = json!({
"track": track_json,
"graph": graph,
"connections": track_connections,
});
serde_json::to_writer_pretty(file, &result)?;
info!("Track template saved successfully to: {}", path);
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_group_as_template(&self, group_name: &str, path: String) -> Task<Message> {
use tracing::info;
let result = (|| -> std::io::Result<()> {
info!("Saving group template to: {}", path);
let template_root = PathBuf::from(&path);
fs::create_dir_all(&path)?;
fs::create_dir_all(template_root.join("plugins"))?;
info!("Created group template directories in: {}", path);
let mut p = template_root.clone();
p.push("group.json");
let file = File::create(&p)?;
let state = self.state.blocking_read();
let group_tracks: Vec<_> = state
.tracks
.iter()
.filter(|t| t.vca_master.as_deref() == Some(group_name))
.collect();
if group_tracks.is_empty() {
return Err(io::Error::new(io::ErrorKind::NotFound, "Group is empty"));
}
let group_track_names: std::collections::HashSet<&str> =
group_tracks.iter().map(|t| t.name.as_str()).collect();
let mut tracks_json = Vec::new();
for track in group_tracks {
let mut track_json = serde_json::to_value(track).map_err(io::Error::other)?;
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![]));
}
if let Some(obj) = track_json.as_object_mut() {
obj.insert("vca_master".to_string(), Value::Null);
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 graph = {
#[cfg(all(unix, not(target_os = "macos")))]
{
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();
json!({
"plugins": plugins_json,
"connections": conns_json,
})
} else {
Value::Null
}
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
Value::Null
}
};
tracks_json.push(json!({
"track": track_json,
"graph": graph,
}));
}
let connections: Vec<&crate::state::Connection> = state
.connections
.iter()
.filter(|c| {
group_track_names.contains(c.from_track.as_str())
&& group_track_names.contains(c.to_track.as_str())
})
.collect();
let result = json!({
"group_name": group_name,
"tracks": tracks_json,
"connections": connections,
});
serde_json::to_writer_pretty(file, &result)?;
info!("Group template saved successfully to: {}", path);
Ok(())
})();
if let Err(e) = result {
Task::done(Message::Response(Err(format!(
"Failed to save group template: {}",
e
))))
} else {
Task::done(Message::None)
}
}
fn track_template_actions_from_json(
_track_name: &str,
_track_json: &Value,
_graph_json: Option<&Value>,
_clap_plugins: &[maolan_engine::clap::ClapPluginInfo],
) -> Vec<Action> {
#[cfg(all(unix, not(target_os = "macos")))]
{
let track_name = _track_name;
let graph_json = _graph_json;
let clap_plugins = _clap_plugins;
let mut actions = vec![];
if let Some(graph) = graph_json.and_then(|g| g.as_object()) {
let mut runtime_nodes = Vec::new();
let mut next_lv2_instance_id = 0usize;
let mut next_clap_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) {
Some("LV2") => {
let instance_id = next_lv2_instance_id;
next_lv2_instance_id += 1;
runtime_nodes.push(
maolan_engine::message::PluginGraphNode::Lv2PluginInstance(
instance_id,
),
);
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)
{
actions.push(Action::TrackSetLv2PluginState {
track_name: track_name.to_string(),
instance_id,
state,
});
}
}
Some("CLAP") => {
let instance_id = next_clap_instance_id;
next_clap_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)
{
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)
{
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;
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,
},
});
}
}
}
actions
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
vec![]
}
}
pub(super) fn load_group_template(
&mut self,
base_name: String,
template_name: String,
) -> Task<Message> {
use std::fs::File;
use std::io::BufReader;
use tracing::info;
info!(
"Loading group template '{}' with base name '{}'",
template_name, base_name
);
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
let template_path = format!(
"{}/.config/maolan/group_templates/{}/group.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 group 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 group template: {}",
e
))));
}
};
let Some(tracks) = json.get("tracks").and_then(|t| t.as_array()) else {
return Task::done(Message::Response(Err(
"Invalid group template: missing tracks".to_string(),
)));
};
let mut name_map = std::collections::HashMap::new();
let mut restore_actions = vec![];
restore_actions.push(Action::BeginSessionRestore);
for track_entry in tracks {
let Some(track_json) = track_entry.get("track") else {
continue;
};
let original_name = track_json
.get("name")
.and_then(Value::as_str)
.unwrap_or("Track");
let new_name = if tracks.len() == 1 {
base_name.clone()
} else {
format!("{} {}", base_name, original_name)
};
name_map.insert(original_name.to_string(), new_name.clone());
let audio_ins = track_json
.get("audio")
.and_then(|a| a.get("ins"))
.and_then(Value::as_u64)
.unwrap_or(1) as usize;
let audio_outs = track_json
.get("audio")
.and_then(|a| a.get("outs"))
.and_then(Value::as_u64)
.unwrap_or(1) as usize;
let midi_ins = track_json
.get("midi")
.and_then(|m| m.get("ins"))
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
let midi_outs = track_json
.get("midi")
.and_then(|m| m.get("outs"))
.and_then(Value::as_u64)
.unwrap_or(0) as usize;
restore_actions.push(Action::AddTrack {
name: new_name.clone(),
audio_ins,
midi_ins,
audio_outs,
midi_outs,
});
if let Some(value) = track_json
.get("midi")
.and_then(|m| m.get("editor_view_mode"))
&& let Ok(mode) =
serde_json::from_value::<crate::message::MidiEditorViewMode>(value.clone())
{
self.pending_track_midi_editor_view_mode
.insert(new_name.clone(), mode);
}
let graph = track_entry.get("graph");
let clap_plugins = self.state.blocking_read().clap_plugins.clone();
let mut plugin_actions =
Self::track_template_actions_from_json(&new_name, track_json, graph, &clap_plugins);
restore_actions.append(&mut plugin_actions);
restore_actions.push(Action::TrackSetVcaMaster {
track_name: new_name,
master_track: Some(base_name.clone()),
});
}
if let Some(connections) = json.get("connections").and_then(|c| c.as_array()) {
for conn in connections {
let Some(from_track_orig) = conn.get("from_track").and_then(Value::as_str) else {
continue;
};
let Some(to_track_orig) = conn.get("to_track").and_then(Value::as_str) else {
continue;
};
let from_track = name_map
.get(from_track_orig)
.cloned()
.unwrap_or_else(|| from_track_orig.to_string());
let to_track = name_map
.get(to_track_orig)
.cloned()
.unwrap_or_else(|| to_track_orig.to_string());
let from_port = conn.get("from_port").and_then(Value::as_u64).unwrap_or(0) as usize;
let to_port = conn.get("to_port").and_then(Value::as_u64).unwrap_or(0) as usize;
let Some(kind_str) = conn.get("kind").and_then(Value::as_str) else {
continue;
};
let kind = match kind_str {
"audio" => Kind::Audio,
"midi" => Kind::MIDI,
_ => continue,
};
restore_actions.push(Action::Connect {
from_track,
from_port,
to_track,
to_port,
kind,
});
}
}
restore_actions.push(Action::EndSessionRestore);
if restore_actions.len() <= 2 {
Task::done(Message::None)
} else {
Self::restore_actions_task(restore_actions)
}
}
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("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 file = File::create(&p)?;
let state = self.state.blocking_read();
let tracks_width = match state.tracks_width {
Length::Fixed(v) => v,
_ => 200.0,
};
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)
{
error!(
"Failed to copy MIDI clip '{}' to '{}': {err}",
src_path.display(),
dst_path.display()
);
}
clip["name"] = Value::String(rel);
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
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);
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();
graphs.insert(
track_name.clone(),
json!({
"plugins": plugins_json,
"connections": conns_json,
}),
);
}
Value::Object(graphs)
};
#[cfg(not(all(unix, not(target_os = "macos"))))]
let graphs = Value::Object(serde_json::Map::new());
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,
"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,
"realtime_frames": state.oss_realtime_frames,
"low_watermark_frames": state.oss_low_watermark_frames,
"hybrid_buffer_enabled": state.oss_hybrid_buffer_enabled,
"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,
"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,
}
});
serde_json::to_writer_pretty(file, &result)?;
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 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 pending_vca_assignments: Vec<(String, 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();
#[cfg(all(unix, not(target_os = "macos")))]
state.plugin_graphs_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)?;
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(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);
let loaded_realtime_frames = transport
.get("realtime_frames")
.and_then(Value::as_u64)
.map(|v| v.max(1) as usize);
let loaded_low_watermark_frames = transport
.get("low_watermark_frames")
.and_then(Value::as_u64)
.map(|v| v.max(1) as usize);
let loaded_hybrid_buffer_enabled = transport
.get("hybrid_buffer_enabled")
.and_then(Value::as_bool);
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;
}
if let Some(v) = loaded_realtime_frames {
self.state.blocking_write().oss_realtime_frames = v;
}
if let Some(v) = loaded_low_watermark_frames {
self.state.blocking_write().oss_low_watermark_frames = v;
}
if let Some(v) = loaded_hybrid_buffer_enabled {
self.state.blocking_write().oss_hybrid_buffer_enabled = v;
}
{
let mut st = self.state.blocking_write();
st.oss_realtime_frames = st.oss_realtime_frames.min(st.oss_period_frames);
st.oss_low_watermark_frames = st.oss_low_watermark_frames.min(st.oss_period_frames);
let step = st.oss_realtime_frames.max(1);
st.oss_low_watermark_frames = (st.oss_low_watermark_frames / step)
.max(1)
.saturating_mul(step)
.min(st.oss_period_frames);
}
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,
},
);
}
{
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;
}
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,
});
{
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 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.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(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);
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,
});
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(value) = track["input_monitor"].as_bool() {
if value {
restore_actions.push(Action::TrackToggleInputMonitor(name.clone()));
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'input_monitor' is not boolean",
));
}
if let Some(value) = track["disk_monitor"].as_bool() {
if !value {
restore_actions.push(Action::TrackToggleDiskMonitor(name.clone()));
}
} else {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"'disk_monitor' is not boolean",
));
}
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 Some(master_name) = track["vca_master"].as_str() {
pending_vca_assignments.push((name.clone(), master_name.to_string()));
}
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(grouped_clip) =
serde_json::from_value::<crate::state::AudioClip>(clip.clone())
{
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;
}
restore_actions.push(Action::AddClip {
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(grouped_clip) =
serde_json::from_value::<crate::state::MIDIClip>(clip.clone())
{
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;
}
restore_actions.push(Action::AddClip {
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}"));
}
}
}
#[cfg(all(unix, not(target_os = "macos")))]
{
let clap_plugins = self.state.blocking_read().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_lv2_instance_id = 0usize;
let mut next_clap_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") => {
let instance_id = next_lv2_instance_id;
next_lv2_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("CLAP") => {
let instance_id = next_clap_instance_id;
next_clap_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(&p["state"]) {
restore_actions.push(Action::TrackClapRestoreState {
track_name: track_name.clone(),
instance_id,
state,
});
}
} 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 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,
});
}
for (track_name, master_track) in pending_vca_assignments {
restore_actions.push(Action::TrackSetVcaMaster {
track_name,
master_track: Some(master_track),
});
}
restore_actions.push(Action::EndSessionRestore);
Ok(Self::restore_actions_task(restore_actions))
}
pub(super) fn refresh_graphs_then_save_template(&mut self, path: String) -> Task<Message> {
#[cfg(all(unix, not(target_os = "macos")))]
{
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();
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()) {
error!("{}", e);
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(),
}),
self.send(Action::TrackSnapshotAllClapStates { track_name }),
]
})
.collect::<Vec<_>>();
Task::batch(tasks)
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
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_clap_tracks = track_names.iter().cloned().collect();
self.pending_save_is_template = true;
if track_names.is_empty() {
let Some(path) = self.pending_save_path.take() else {
return Task::none();
};
if let Err(e) = self.save_template(path.clone()) {
error!("{}", e);
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()
.map(|track_name| self.send(Action::TrackSnapshotAllClapStates { track_name }))
.collect::<Vec<_>>();
Task::batch(tasks)
}
}
pub(super) fn refresh_graphs_then_save(&mut self, path: String) -> Task<Message> {
#[cfg(all(unix, not(target_os = "macos")))]
{
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();
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()) {
error!("{}", e);
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));
}
let tasks = track_names
.into_iter()
.flat_map(|track_name| {
vec![
self.send(Action::TrackGetPluginGraph {
track_name: track_name.clone(),
}),
self.send(Action::TrackSnapshotAllClapStates { track_name }),
]
})
.collect::<Vec<_>>();
Task::batch(tasks)
}
#[cfg(not(all(unix, not(target_os = "macos"))))]
{
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_clap_tracks = track_names.iter().cloned().collect();
self.pending_save_is_template = false;
if track_names.is_empty() {
let Some(path) = self.pending_save_path.take() else {
return Task::none();
};
if let Err(e) = self.save(path.clone()) {
error!("{}", e);
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));
}
let tasks = track_names
.into_iter()
.map(|track_name| self.send(Action::TrackSnapshotAllClapStates { track_name }))
.collect::<Vec<_>>();
Task::batch(tasks)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[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 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));
}