Plugin presets: factory default and user presets (save/recall), mix.insert_params

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-05 23:51:56 -04:00
parent 718dccdad7
commit f534ca4e15
4 changed files with 100 additions and 0 deletions

View File

@ -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;
}

View File

@ -95,6 +95,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
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<Value> {
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"))?;

View File

@ -136,6 +136,11 @@ pub struct Services {
pub pick_save: Option<Box<dyn Fn(&str, &str) -> Option<String>>>,
}
/// 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<std::path::PathBuf>,
/// Folder for user plugin presets (native apps set it).
pub preset_dir: Option<std::path::PathBuf>,
last_autosave: f64,
pub dialogs: dialogs::Dialogs,
pub meters: HashMap<TrackId, MeterDisplay>,
@ -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(),

View File

@ -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<std::path::PathBuf> {
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("<factory default>").clicked() {
let defaults: serde_json::Map<String, serde_json::Value> = 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": "<factory default>"}));
ui.close();
}
let Some(dir) = preset_dir(app, info.id) else {
ui.label("User presets need the desktop app.");
return;
};
let mut names: Vec<String> = 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::<serde_json::Value>(&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));
}