From f534ca4e1502b4cfc631bfeb66bf1d7f4d9deab8 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 23:51:56 -0400 Subject: [PATCH] Plugin presets: factory default and user presets (save/recall), mix.insert_params Co-Authored-By: Claude Opus 5.5 --- apps/soundcraft/src/main.rs | 1 + crates/engine/src/cmd/mix.rs | 33 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 8 +++++ crates/ui-egui/src/panels.rs | 58 ++++++++++++++++++++++++++++++++++++ 4 files changed, 100 insertions(+) diff --git a/apps/soundcraft/src/main.rs b/apps/soundcraft/src/main.rs index dcf39b1..a6cafa1 100644 --- a/apps/soundcraft/src/main.rs +++ b/apps/soundcraft/src/main.rs @@ -175,6 +175,7 @@ fn main() -> eframe::Result { let player = if no_audio { None } else { Some(soundcraft_playback::Player::new(Arc::new(engine.session().clone()))) }; let mut app = SoundApp::new(engine, player, services()); app.autosave_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Autosave"))); + app.preset_dir = prefs_path().and_then(|p| p.parent().map(|d| d.join("Presets"))); if let Some(ui) = load_prefs() { app.ui = ui; } diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 5c1407d..03574b2 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -95,6 +95,15 @@ pub fn specs() -> Vec { cmd!("mix.insert_remove", "Remove Insert", [], None, "{track?, slot}", has_selection, insert_remove), cmd!("mix.insert_bypass", "Bypass Insert", [], None, "{track?, slot, value?: bool}", has_selection, insert_bypass), cmd!("mix.insert_param", "Set Plugin Parameter", [], None, "{track?, slot, param, value}", has_selection, insert_param), + cmd!( + "mix.insert_params", + "Set Plugin Parameters", + [], + None, + "{track?, slot, params: {id: value}, preset?: name}", + has_selection, + insert_params + ), cmd!("mix.insert_move", "Move Insert", [], None, "{track?, from, to}", has_selection, insert_move), cmd!("mix.send", "Assign Send", [], None, "{track?, slot?: 0..9|a..j, bus: name, level_db?: 0, pre_fader?: false}", has_selection, send), cmd!("mix.send_remove", "Remove Send", [], None, "{track?, slot}", has_selection, |e, p| { @@ -343,6 +352,30 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result { Ok(json!({"value": v})) } +/// Apply many parameters at once (preset recall); unknown ids are ignored, values clamped. +fn insert_params(e: &mut Engine, p: &Value) -> Result { + let (t, slot) = track_slot(e, p, "mix.insert_params", INSERT_SLOTS)?; + let obj = p.get("params").and_then(Value::as_object).cloned().ok_or_else(|| bad("mix.insert_params", "`params` must be an object"))?; + let preset = str_param(p, "preset").map(str::to_string); + let s = e.session_mut(); + let ins = s + .track_mut(t) + .and_then(|tr| tr.mixer.inserts.get_mut(slot)) + .and_then(Option::as_mut) + .ok_or_else(|| bad("mix.insert_params", "empty slot"))?; + let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_params", "unknown plugin"))?; + let mut n = 0; + for (k, v) in obj { + let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) else { continue }; + ins.params.insert(k, (f as f32).clamp(pi.min, pi.max)); + n += 1; + } + if let Some(name) = preset { + ins.preset = name; + } + Ok(json!({"set": n})) +} + fn insert_move(e: &mut Engine, p: &Value) -> Result { let t = tracks_required(e, "mix.insert_move", p)?.first().copied().ok_or_else(|| bad("mix.insert_move", "no track"))?; let from = slot_of(p, "from", INSERT_SLOTS).ok_or_else(|| bad("mix.insert_move", "`from` slot"))?; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 96e24e3..b966fac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -136,6 +136,11 @@ pub struct Services { pub pick_save: Option Option>>, } +/// File-system-safe folder name for a plugin id (`clap:com.x.y` → `clap_com.x.y`). +pub fn preset_folder_name(plugin: &str) -> String { + plugin.chars().map(|c| if c.is_ascii_alphanumeric() || c == '.' || c == '-' || c == '_' { c } else { '_' }).collect() +} + /// Ballistic meter display state per strip. #[derive(Debug, Clone, Copy, Default)] pub struct MeterDisplay { @@ -173,6 +178,8 @@ pub struct SoundApp { pub services: Services, /// Folder for crash-recovery autosaves (native apps set it). pub autosave_dir: Option, + /// Folder for user plugin presets (native apps set it). + pub preset_dir: Option, last_autosave: f64, pub dialogs: dialogs::Dialogs, pub meters: HashMap, @@ -212,6 +219,7 @@ impl SoundApp { ui: UiState::default(), services, autosave_dir: None, + preset_dir: None, last_autosave: 0.0, dialogs: dialogs::Dialogs::default(), meters: HashMap::new(), diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index c4a891b..112edcc 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -319,6 +319,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) { if ui.button(label).clicked() { let _ = app.run("mix.insert_bypass", json!({"track": tid.0, "slot": slot})); } + ui.menu_button("Presets ▾", |ui| preset_menu(app, ui, tid, slot, info, &ins)); }); if info.id == "eq_7band" || info.id == "eq_1band" { eq_curve(ui, info.id, &ins); @@ -419,3 +420,60 @@ fn session_info(app: &mut SoundApp, ctx: &egui::Context) { }); app.ui.show_session_info = open; } + +/// Folder for a plugin's user presets. +fn preset_dir(app: &SoundApp, plugin: &str) -> Option { + app.preset_dir.as_ref().map(|d| d.join(crate::preset_folder_name(plugin))) +} + +fn preset_menu( + app: &mut SoundApp, + ui: &mut Ui, + tid: soundcraft_model::TrackId, + slot: usize, + info: &soundcraft_dsp::PluginInfo, + ins: &soundcraft_model::Insert, +) { + if ui.button("").clicked() { + let defaults: serde_json::Map = info.params.iter().map(|p| (p.id.to_string(), json!(p.default))).collect(); + let _ = app.run("mix.insert_params", json!({"track": tid.0, "slot": slot, "params": defaults, "preset": ""})); + ui.close(); + } + let Some(dir) = preset_dir(app, info.id) else { + ui.label("User presets need the desktop app."); + return; + }; + let mut names: Vec = std::fs::read_dir(&dir) + .map(|rd| rd.flatten().filter_map(|e| e.path().file_stem().and_then(|x| x.to_str()).map(str::to_string)).collect()) + .unwrap_or_default(); + names.sort(); + for n in &names { + if ui.button(n).clicked() { + if let Ok(text) = std::fs::read_to_string(dir.join(format!("{n}.json"))) + && let Ok(v) = serde_json::from_str::(&text) + { + let _ = app.run( + "mix.insert_params", + json!({"track": tid.0, "slot": slot, "params": v.get("params").cloned().unwrap_or_default(), "preset": n}), + ); + } + ui.close(); + } + } + ui.separator(); + let key = egui::Id::new(("preset_name", tid.0, slot)); + let mut name: String = ui.ctx().memory(|m| m.data.get_temp(key)).unwrap_or_else(|| format!("{} preset", info.name)); + ui.horizontal(|ui| { + ui.add(egui::TextEdit::singleline(&mut name).desired_width(140.0)); + if ui.button("Save").clicked() { + let clean = soundcraft_engine::io::sanitize_name(&name); + let body = json!({"format": "soundcraft-plugin-preset", "plugin": info.id, "params": ins.params}); + let ok = std::fs::create_dir_all(&dir).is_ok() && std::fs::write(dir.join(format!("{clean}.json")), body.to_string()).is_ok(); + app.ui.status = if ok { format!("Saved preset {clean}") } else { "Could not save the preset".into() }; + if ok { + let _ = app.run("mix.insert_params", json!({"track": tid.0, "slot": slot, "params": {}, "preset": clean})); + } + } + }); + ui.ctx().memory_mut(|m| m.data.insert_temp(key, name)); +}