collective

Browse / github.com/storytold/soundcraft / patch

Mix: list a plugin's parameters (mix.plugin_params) (#1065, part 2)

Mix: list a plugin's parameters (mix.plugin_params) (#1065, part 2) > **Builds on #1066.** The first five commits are #1066's. This PR adds the last two: `3403cb2` and `d0dbce4`. Review those, or this one after #1066 merges. ## What Part 2 of #1065: a read-only query that lists the parameters of the plugin in an insert slot or a track's instrument. Built-in, CLAP, VST3 and Audio Units all work the same way. ``` mix.plugin_params {track?, slot: 0..9 | a..j | "instrument", filter?, offset?: 0, limit?: 200 (1..1000)} → {plugin, name, total, offset, params: [{id, name, min, max, default, unit, taper, steps, values, value, value_source}]} ``` - Metadata comes f

Status
open
Score
0.00
Adopters
0 other operators
Author
sanderdewijs (imported from pull request #1071, open)
Base
7dc56a4dbeebf57eacece09536726c57844fc59d
Size
+1386 −97 in 14 files
Review
pending
Shared
2026-10-28
Id
073c105b314a1e0d02c0a5c2

How it went

No other operator has reported using it yet.

Diff

raw · contributors/people.toml crates/engine/src/cmd/instrument.rs crates/engine/src/cmd/mix.rs crates/engine/src/cmd/mod.rs crates/engine/src/cmd/plugin_params.rs crates/engine/src/cmd/plugin_state.rs crates/mix/src/lib.rs crates/model/src/automation.rs crates/model/src/lib.rs crates/model/src/track.rs crates/ui-egui/src/edit_window.rs crates/ui-egui/src/lib.rs docs/mcp.md docs/plugin-parity.md

diff --git a/contributors/people.toml b/contributors/people.toml--- a/contributors/people.toml+++ b/contributors/people.toml@@ -61,3 +61,8 @@ display_name = "Matt Smith-Burlage"  [people.ProdByBuddha] sync_github_name = true++[people.sanderdewijs]+real_name = "Sander de Wijs"+display_name = "Sander de Wijs"+sync_github_name = falsediff --git a/crates/engine/src/cmd/instrument.rs b/crates/engine/src/cmd/instrument.rs--- a/crates/engine/src/cmd/instrument.rs+++ b/crates/engine/src/cmd/instrument.rs@@ -1,7 +1,9 @@ //! An instrument track's instrument (`mix.instrument`): the plugin that turns the track's MIDI //! into audio, built in or hosted (CLAP, VST3, Audio Units). The mixer plays `Track::instrument`;-//! the instrument is otherwise only set when the track is created (`track.new`).-use super::{CommandSpec, bad, has_selection, str_param, tracks_required};+//! the instrument is otherwise only set when the track is created (`track.new`). Its parameters+//! (`mix.instrument_param`, or `mix.insert_param` with `slot: "instrument"`) are applied on top+//! of a hosted instrument's stored state, like an insert's.+use super::{CommandSpec, bad, has_selection, param_id, str_param, tracks_required};  use crate::{Engine, Result, cmd}; use serde_json::{Value, json};@@ -23,7 +25,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Set Instrument Parameter",             [],             None,-            "{track?, param, value}: one parameter of an instrument track's built-in instrument (clamped into range)",+            "{track?, param: id (string or number), value}: one parameter of an instrument track's instrument, built in or hosted (clamped into range); the same as mix.insert_param with slot \"instrument\"",             has_selection,             set_instrument_param         ),@@ -75,12 +77,11 @@ fn set_instrument(e: &mut Engine, p: &Value) -> Result<Value> {  fn set_instrument_param(e: &mut Engine, p: &Value) -> Result<Value> {     let id = "mix.instrument_param";-    let param = str_param(p, "param").ok_or_else(|| bad(id, "`param` required"))?.to_string();+    let param = param_id(p, "param").ok_or_else(|| bad(id, "`param` required (a string, or a whole number)"))?;     let value = p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(id, "`value` must be a number"))? as f32;     let t = tracks_required(e, id, p)?.first().copied().ok_or_else(|| bad(id, "no track"))?;     let ins = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()).ok_or_else(|| bad(id, "the track has no instrument"))?;-    // Hosted instruments keep their parameters in their own state.-    let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("`{}` is not a built-in instrument", ins.plugin)))?;+    let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad(id, format!("unknown plugin `{}`", ins.plugin)))?;     let pi = info.param(&param).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?;     let v = pi.clamp(value);     ins.params.insert(param.clone(), v);diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs--- a/crates/engine/src/cmd/mix.rs+++ b/crates/engine/src/cmd/mix.rs@@ -1,6 +1,7 @@ //! Mixer, insert, send and automation commands. use super::{-    CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, position_param, range_param, str_param, tracks_required,+    CommandSpec, always, bad, bool_or, f32_or, has_range, has_selection, has_tracks, i64_or, param_id, position_param, range_param, str_param,+    tracks_required, };  use crate::{Engine, EngineError, Result, cmd};@@ -97,13 +98,21 @@ pub fn specs() -> Vec<CommandSpec> {         cmd!("mix.insert", "Insert Plugin", [], None, "{track?, slot?: 0..9 | a..j, plugin: id, params?: {id: value}}", has_selection, insert),         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_param",+            "Set Plugin Parameter",+            [],+            None,+            "{track?, slot: 0..9 | a..j | \"instrument\", param: id (string or number), value}",+            has_selection,+            insert_param+        ),         cmd!(             "mix.insert_params",             "Set Plugin Parameters",             [],             None,-            "{track?, slot, params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)",+            "{track?, slot: 0..9 | a..j | \"instrument\", params: {id: value}, preset?: name} (unknown ids and non-numeric values are errors)",             has_selection,             insert_params         ),@@ -138,7 +147,7 @@ pub fn specs() -> Vec<CommandSpec> {             "Add Automation Breakpoint",             [],             None,-            "{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>, at, value, from?}",+            "{track?, param: volume|pan|mute|send_a_level|plugin:a:<id>|plugin:instrument:<id>, at, value, from?}",             has_selection,             set_point         ),@@ -312,6 +321,30 @@ fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId,     Ok((t, slot)) } +/// The plugin a parameter command addresses: an insert slot, or the instrument of an+/// instrument or MIDI track (`slot: "instrument"`).+#[derive(Debug, Clone, Copy, PartialEq, Eq)]+pub(super) enum PluginSlot {+    Insert(usize),+    Instrument,+}++pub(super) fn track_plugin_slot(e: &Engine, p: &Value, cmd: &str) -> Result<(TrackId, PluginSlot)> {+    if p.get("slot").and_then(Value::as_str).is_some_and(|s| s.trim().eq_ignore_ascii_case("instrument")) {+        let t = tracks_required(e, cmd, p)?.first().copied().ok_or_else(|| bad(cmd, "no track"))?;+        return Ok((t, PluginSlot::Instrument));+    }+    track_slot(e, p, cmd, INSERT_SLOTS).map(|(t, i)| (t, PluginSlot::Insert(i)))+}++fn plugin_mut<'a>(s: &'a mut Session, t: TrackId, slot: PluginSlot, cmd: &str) -> Result<&'a mut Insert> {+    let tr = s.track_mut(t).ok_or_else(|| bad(cmd, "no track"))?;+    match slot {+        PluginSlot::Insert(i) => tr.mixer.inserts.get_mut(i).and_then(Option::as_mut).ok_or_else(|| bad(cmd, "empty slot")),+        PluginSlot::Instrument => tr.instrument.as_mut().ok_or_else(|| bad(cmd, "the track has no instrument")),+    }+}+ /// An optional `slot`: absent (or null) means "first free"; a present but invalid value is /// an error, never silently treated as absent. fn optional_slot(p: &Value, cmd: &str, max: usize) -> Result<Option<usize>> {@@ -339,7 +372,7 @@ fn insert(e: &mut Engine, p: &Value) -> Result<Value> {             let Some(pi) = info.params.iter().find(|x| x.id == k) else {                 return Err(bad("mix.insert", format!("`{plugin}` has no parameter `{k}`")));             };-            let val = v.as_f64().map_or(pi.default, |f| (f as f32).clamp(pi.min, pi.max));+            let val = v.as_f64().map_or(pi.default, |f| pi.clamp(f as f32));             ins.params.insert(k.clone(), val);         }     }@@ -373,17 +406,15 @@ fn insert_bypass(e: &mut Engine, p: &Value) -> Result<Value> { }  fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {-    let (t, slot) = track_slot(e, p, "mix.insert_param", INSERT_SLOTS)?;-    let param = str_param(p, "param").ok_or_else(|| bad("mix.insert_param", "`param` required"))?.to_string();+    let (t, slot) = track_plugin_slot(e, p, "mix.insert_param")?;+    let param = param_id(p, "param").ok_or_else(|| bad("mix.insert_param", "`param` required (a string, or a whole number)"))?;     let value =         p.get("value").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad("mix.insert_param", "`value` must be a number"))? as f32;-    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_param", "empty slot"))?;+    let ins = plugin_mut(e.session_mut(), t, slot, "mix.insert_param")?;     let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?;     let pi =         info.params.iter().find(|x| x.id == param).ok_or_else(|| bad("mix.insert_param", format!("`{}` has no parameter `{param}`", info.id)))?;-    let v = value.clamp(pi.min, pi.max);+    let v = pi.clamp(value);     ins.params.insert(param, v);     Ok(json!({"value": v})) }@@ -391,15 +422,10 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> { /// Apply many parameters at once (preset recall); values are clamped. Unknown ids and /// non-numeric values are errors, not silent no-ops: a typo must not look like success. fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {-    let (t, slot) = track_slot(e, p, "mix.insert_params", INSERT_SLOTS)?;+    let (t, slot) = track_plugin_slot(e, p, "mix.insert_params")?;     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 ins = plugin_mut(e.session_mut(), t, slot, "mix.insert_params")?;     let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_params", "unknown plugin"))?;     let mut unknown: Vec<&str> = Vec::new();     let mut non_numeric: Vec<&str> = Vec::new();@@ -421,7 +447,7 @@ fn insert_params(e: &mut Engine, p: &Value) -> Result<Value> {     let mut n = 0;     for (k, v) in obj {         if let (Some(pi), Some(f)) = (info.params.iter().find(|x| x.id == k), v.as_f64().filter(|f| f.is_finite())) {-            ins.params.insert(k, (f as f32).clamp(pi.min, pi.max));+            ins.params.insert(k, pi.clamp(f as f32));             n += 1;         }     }@@ -533,9 +559,15 @@ pub(crate) fn current_value(tr: &soundcraft_model::Track, param: &AutoParam) ->         AutoParam::SendLevel(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(-144.0, |s| s.level_db),         AutoParam::SendPan(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| s.pan),         AutoParam::SendMute(i) => tr.mixer.sends.get(usize::from(*i)).and_then(Option::as_ref).map_or(0.0, |s| f32::from(u8::from(s.mute))),-        AutoParam::Plugin { slot, param } => {-            tr.mixer.inserts.get(usize::from(*slot)).and_then(Option::as_ref).and_then(|i| i.params.get(param).copied()).unwrap_or(0.0)-        }+        // A parameter the session holds no value for (a hosted instrument made by `track.new`)+        // starts from the plugin's default, not 0.+        AutoParam::Plugin { slot, param } => tr.plugin_at(*slot).map_or(0.0, |i| {+            i.params+                .get(param)+                .copied()+                .or_else(|| super::clap::plugin_info(&i.plugin).and_then(|info| info.param(param)).map(|pi| pi.default))+                .unwrap_or(0.0)+        }),     } } @@ -1118,4 +1150,115 @@ mod tests {         let r = e.execute("mix.send", &json!({"track": "Gtr", "bus": "Verb"})).unwrap();         assert_eq!(r["slot"], 0);     }++    fn instrument_params(e: &Engine, t: TrackId) -> std::collections::BTreeMap<String, f32> {+        e.session().track(t).and_then(|tr| tr.instrument.as_ref()).map(|i| i.params.clone()).unwrap_or_default()+    }++    #[test]+    fn the_instrument_slot_takes_parameters_like_an_insert() {+        let mut e = Engine::default();+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();+        let t = e.session().tracks[0].id;+        let r = e.execute("mix.insert_param", &json!({"track": t.0, "slot": "Instrument", "param": "cutoff", "value": 5000})).unwrap();+        assert_eq!(r["value"], 5000.0);+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&5000.0));+        let r = e.execute("mix.insert_params", &json!({"track": t.0, "slot": "instrument", "params": {"cutoff": 1.0e9, "level": -12}})).unwrap();+        assert_eq!(r["set"], 2);+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&20_000.0), "clamped");+        assert_eq!(instrument_params(&e, t).get("level"), Some(&-12.0));+        e.execute("edit.undo", &json!({})).unwrap();+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&5000.0));++        // Same checks as on an insert: unknown ids and bad values change nothing.+        for bad in [+            json!({"track": t.0, "slot": "instrument", "param": "nope", "value": 1}),+            json!({"track": t.0, "slot": "instrument", "param": "cutoff", "value": "open"}),+            json!({"track": t.0, "slot": "instrument", "param": -3, "value": 1}),+            json!({"track": t.0, "slot": "instrument", "param": 1.5, "value": 1}),+            json!({"track": t.0, "slot": "instruments", "param": "cutoff", "value": 1}),+        ] {+            assert!(e.execute("mix.insert_param", &bad).is_err(), "{bad}");+        }+        assert!(e.execute("mix.insert_params", &json!({"track": t.0, "slot": "instrument", "params": {"nope": 1}})).is_err());+        assert_eq!(instrument_params(&e, t).get("cutoff"), Some(&5000.0));++        // An audio track has no instrument; other insert commands keep refusing the name.+        e.execute("track.new", &json!({})).unwrap();+        let audio = e.session().tracks[1].id.0;+        let err = e.execute("mix.insert_param", &json!({"track": audio, "slot": "instrument", "param": "cutoff", "value": 1})).unwrap_err();+        assert!(err.to_string().contains("no instrument"), "{err}");+        assert!(e.execute("mix.insert_remove", &json!({"track": t.0, "slot": "instrument"})).is_err());+        assert!(e.execute("mix.insert_bypass", &json!({"track": t.0, "slot": "instrument"})).is_err());+    }++    #[test]+    fn instrument_automation_lanes_take_the_stored_value() {+        let mut e = Engine::default();+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();+        let t = e.session().tracks[0].id;+        e.execute("mix.instrument_param", &json!({"track": t.0, "param": "cutoff", "value": 800})).unwrap();+        let param = AutoParam::parse("plugin:instrument:cutoff").unwrap();+        let tr = e.session().track(t).unwrap();+        assert_eq!(current_value(tr, &param), 800.0);+        e.execute("automation.set_point", &json!({"tracks": [t.0], "param": "plugin:instrument:cutoff", "at": 0, "value": 300})).unwrap();+        let lane = e.session().track(t).and_then(|tr| tr.lane(&param)).map(|l| l.value_at(0, 0.0));+        assert_eq!(lane, Some(300.0));+    }++    #[test]+    fn toggles_and_choices_are_rounded_like_instrument_param() {+        let mut e = Engine::default();+        e.execute("track.new", &json!({})).unwrap();+        let t = e.session().tracks[0].id;+        e.execute("mix.insert", &json!({"track": t.0, "slot": 0, "plugin": "eq_7band", "params": {"hpf_on": 0.7}})).unwrap();+        let stored = |e: &Engine, k: &str| e.session().track(t).and_then(|tr| tr.mixer.inserts[0].as_ref()).and_then(|i| i.params.get(k).copied());+        assert_eq!(stored(&e, "hpf_on"), Some(1.0), "mix.insert");+        e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "hpf_on", "value": 0.3})).unwrap();+        assert_eq!(stored(&e, "hpf_on"), Some(0.0), "mix.insert_param");+        e.execute("mix.insert_params", &json!({"track": t.0, "slot": 0, "params": {"hpf_slope": 1.6}})).unwrap();+        assert_eq!(stored(&e, "hpf_slope"), Some(2.0), "mix.insert_params");+    }++    #[test]+    fn a_lane_written_on_an_unstored_instrument_parameter_starts_from_its_default() {+        let mut e = Engine::default();+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();+        let t = e.session().tracks[0].id;+        if let Some(ins) = e.session_mut().track_mut(t).and_then(|tr| tr.instrument.as_mut()) {+            ins.params.clear();+        }+        let default = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).map(|p| p.default).unwrap();+        e.execute(+            "automation.write_range",+            &json!({"tracks": [t.0], "param": "plugin:instrument:cutoff", "start": 48_000, "end": 96_000, "value": 300}),+        )+        .unwrap();+        let param = AutoParam::parse("plugin:instrument:cutoff").unwrap();+        let lane = e.session().track(t).and_then(|tr| tr.lane(&param)).cloned().unwrap();+        assert_eq!(lane.value_at(0, f32::NAN), default, "before the range: the default, not 0");+        assert_eq!(lane.value_at(72_000, f32::NAN), 300.0);+    }++    /// Apple's DLS synth ships with every Mac: a hosted instrument's parameters can be set by+    /// numeric id on its slot, which used to answer "not a built-in instrument".+    #[cfg(target_os = "macos")]+    #[test]+    fn a_hosted_instruments_parameters_are_set_by_numeric_id() {+        let id = "au:aumu:dls :appl";+        let Some(pi) = super::super::clap::plugin_info(id).and_then(|i| i.params.iter().find(|p| p.unit != soundcraft_dsp::Unit::Choice)) else {+            return; // no parameters reported on this system+        };+        let n: u32 = pi.id.parse().unwrap();+        let mut e = Engine::default();+        e.execute("track.new", &json!({"kind": "instrument", "instrument": id})).unwrap();+        let t = e.session().tracks[0].id;+        let v = (pi.min + pi.max) / 2.0;+        e.execute("mix.instrument_param", &json!({"track": t.0, "param": n, "value": v})).unwrap();+        assert_eq!(instrument_params(&e, t).get(pi.id), Some(&v));+        e.execute("mix.insert_param", &json!({"track": t.0, "slot": "instrument", "param": n, "value": pi.max})).unwrap();+        assert_eq!(instrument_params(&e, t).get(pi.id), Some(&pi.max));+        let auto = format!("plugin:instrument:{n}");+        e.execute("automation.set_point", &json!({"tracks": [t.0], "param": auto, "at": 0, "value": pi.min})).unwrap();+    } }diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs--- a/crates/engine/src/cmd/mod.rs+++ b/crates/engine/src/cmd/mod.rs@@ -24,6 +24,7 @@ mod midi; mod mix; mod more_util; mod options;+mod plugin_params; mod plugin_state; mod query; pub(crate) mod sampler;@@ -155,6 +156,7 @@ pub fn command_specs() -> &'static [CommandSpec] {         v.extend(clap::specs());         v.extend(vst3::specs());         v.extend(surround::specs());+        v.extend(plugin_params::specs());         v.extend(plugin_state::specs());         v.extend(instrument::specs());         v.extend(sampler::specs());@@ -232,6 +234,13 @@ pub fn str_param<'a>(p: &'a Value, key: &str) -> Option<&'a str> {     p.get(key).and_then(Value::as_str) } +/// A plugin parameter id: a string, or a whole number in `u32` range (hosted plugins' ids are+/// decimal `u32`s, such as an Audio Unit's `1575`).+pub fn param_id(p: &Value, key: &str) -> Option<String> {+    let v = p.get(key)?;+    v.as_str().map(str::to_string).or_else(|| v.as_u64().and_then(|n| u32::try_from(n).ok()).map(|n| n.to_string()))+}+ /// Resolve a track from `track` (id number or name) params. pub fn track_param(e: &Engine, cmd: &str, p: &Value, key: &str) -> Result<Option<TrackId>> {     let Some(v) = p.get(key) else { return Ok(None) };diff --git a/crates/engine/src/cmd/plugin_params.rs b/crates/engine/src/cmd/plugin_params.rsnew file mode 100644--- /dev/null+++ b/crates/engine/src/cmd/plugin_params.rs@@ -0,0 +1,316 @@+//! Read-only plugin parameter metadata for agents (`mix.plugin_params`).+use super::mix::{PluginSlot, track_plugin_slot};+use super::{CommandSpec, bad, has_selection};++use crate::{Engine, Result, cmd};+use serde_json::{Value, json};+use soundcraft_dsp::{ParamInfo, PluginInfo, Unit};+use soundcraft_model::Insert;++const CMD: &str = "mix.plugin_params";+const DEFAULT_LIMIT: usize = 200;+const MAX_LIMIT: usize = 1000;++pub fn specs() -> Vec<CommandSpec> {+    vec![cmd!(+        query "mix.plugin_params",+        "List Plugin Parameters",+        [],+        None,+        "{track?, slot: 0..9 | a..j | \"instrument\", filter?: string (max 200 characters), offset?: 0, limit?: 200 (1..1000)} → {plugin, name, total, offset, params: [{id, name, min, max, default, unit, taper, steps, values, value, value_source}]}. Filter is a Unicode-lowercase substring of id or name; total is the filtered count before pagination. Unit is None|Db|Hz|Ms|Percent|Ratio|Semitones|Cents|Seconds|Toggle|Choice; taper is Linear|Log. Toggle/Choice value is a numeric index into values; Toggle values are always [\"Off\",\"On\"] (host-provided toggle strings are not yet read), and continuous values is []. Non-finite min/max/default/value is null. value_source \"session\" means the insert's stored parameter map holds the key (mix.insert stores every default; track.new's instrument starts empty); \"default\" means it does not. For hosted plugins the real value may instead live in the plugin state blob or automation; reading it live is a later addition.",+        has_selection,+        plugin_params+    )]+}++fn nonnegative_integer(p: &Value, key: &str, default: usize) -> Result<usize> {+    match p.get(key) {+        None => Ok(default),+        Some(value) => {+            value.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer")))+        }+    }+}++fn pagination(p: &Value) -> Result<(usize, usize)> {+    let offset = nonnegative_integer(p, "offset", 0)?;+    let limit = nonnegative_integer(p, "limit", DEFAULT_LIMIT)?;+    if !(1..=MAX_LIMIT).contains(&limit) {+        return Err(bad(CMD, "`limit` must be an integer from 1 through 1000"));+    }+    Ok((offset, limit))+}++fn matches_filter(param: &ParamInfo, needle: &str) -> bool {+    param.id.to_lowercase().contains(needle) || param.name.to_lowercase().contains(needle)+}++fn finite(value: f32) -> Option<f32> {+    value.is_finite().then_some(value)+}++fn parameter_json(param: &ParamInfo, insert: &Insert) -> Value {+    let (steps, values): (Option<usize>, Vec<&str>) = match param.unit {+        Unit::Toggle => (Some(2), vec!["Off", "On"]),+        Unit::Choice => (Some(param.choices.len()), param.choices.to_vec()),+        _ => (None, Vec::new()),+    };+    let (value, value_source) = insert.params.get(param.id).map_or((param.default, "default"), |value| (*value, "session"));+    json!({+        "id": param.id,+        "name": param.name,+        "min": finite(param.min),+        "max": finite(param.max),+        "default": finite(param.default),+        "unit": param.unit,+        "taper": param.taper,+        "steps": steps,+        "values": values,+        "value": finite(value),+        "value_source": value_source,+    })+}++fn response(info: &PluginInfo, insert: &Insert, filter: Option<&str>, offset: usize, limit: usize) -> Value {+    let matching: Vec<_> = info.params.iter().filter(|param| filter.is_none_or(|needle| matches_filter(param, needle))).collect();+    let total = matching.len();+    let params: Vec<_> = matching.into_iter().skip(offset).take(limit).map(|param| parameter_json(param, insert)).collect();+    json!({"plugin": info.id, "name": info.name, "total": total, "offset": offset, "params": params})+}++fn plugin_params(e: &mut Engine, p: &Value) -> Result<Value> {+    let (track, slot) = track_plugin_slot(e, p, CMD)?;+    let tr = e.session().track(track).ok_or_else(|| bad(CMD, "no track"))?;+    let insert = match slot {+        PluginSlot::Insert(index) => tr.mixer.inserts.get(index).and_then(Option::as_ref).ok_or_else(|| bad(CMD, "empty slot"))?,+        PluginSlot::Instrument => tr.instrument.as_ref().ok_or_else(|| bad(CMD, "the track has no instrument"))?,+    };+    let info = super::clap::plugin_info(&insert.plugin).ok_or_else(|| bad(CMD, "unknown plugin"))?;+    let filter = match p.get("filter") {+        None => None,+        Some(value) => {+            let value = value.as_str().ok_or_else(|| bad(CMD, "`filter` must be a string"))?;+            if value.chars().count() > 200 {+                return Err(bad(CMD, "`filter` must be at most 200 characters"));+            }+            Some(value.to_lowercase())+        }+    };+    let (offset, limit) = pagination(p)?;+    Ok(response(info, insert, filter.as_deref(), offset, limit))+}++#[cfg(test)]+mod tests {+    use super::{CMD, DEFAULT_LIMIT, matches_filter, pagination, parameter_json};+    use crate::Engine;+    use serde_json::{Value, json};+    use soundcraft_dsp::{ParamInfo, Taper, Unit};+    use soundcraft_model::Insert;++    fn new_track(e: &mut Engine, kind: Option<&str>) -> u64 {+        let params = kind.map_or_else(|| json!({}), |kind| json!({"kind": kind}));+        e.execute("track.new", &params).unwrap();+        e.session().tracks.last().unwrap().id.0+    }++    fn insert_eq(e: &mut Engine) -> u64 {+        let track = new_track(e, None);+        e.execute("mix.insert", &json!({"track": track, "slot": 0, "plugin": "eq_7band"})).unwrap();+        track+    }++    fn listed_params(value: &Value) -> &[Value] {+        value.get("params").and_then(Value::as_array).map(Vec::as_slice).unwrap_or(&[])+    }++    #[test]+    fn built_in_insert_lists_metadata_and_stored_values() {+        let mut e = Engine::default();+        let track = insert_eq(&mut e);+        e.execute("mix.insert_param", &json!({"track": track, "slot": 0, "param": "mid_gain", "value": 7.5})).unwrap();+        if let Some(insert) =+            e.session_mut().track_mut(soundcraft_model::TrackId(track)).and_then(|track| track.mixer.inserts.first_mut()).and_then(Option::as_mut)+        {+            insert.params.remove("input_gain");+        }+        let result = e.execute(CMD, &json!({"track": track, "slot": "a", "limit": 1000})).unwrap();+        let info = soundcraft_dsp::plugin_info("eq_7band").unwrap();+        let params = listed_params(&result);++        assert_eq!(result.get("plugin"), Some(&json!(info.id)));+        assert_eq!(result.get("name"), Some(&json!(info.name)));+        assert_eq!(result.get("total"), Some(&json!(info.params.len())));+        assert_eq!(params.len(), info.params.len());+        for (actual, expected) in params.iter().zip(info.params) {+            assert_eq!(actual.get("id"), Some(&json!(expected.id)));+            assert_eq!(actual.get("name"), Some(&json!(expected.name)));+            assert_eq!(actual.get("min"), Some(&json!(expected.min)));+            assert_eq!(actual.get("max"), Some(&json!(expected.max)));+            assert_eq!(actual.get("default"), Some(&json!(expected.default)));+        }+        let changed = params.iter().find(|param| param.get("id") == Some(&json!("mid_gain"))).unwrap();+        assert_eq!(changed.get("value"), Some(&json!(7.5)));+        assert_eq!(changed.get("value_source"), Some(&json!("session")));+        let defaulted = params.iter().find(|param| param.get("id") == Some(&json!("input_gain"))).unwrap();+        assert_eq!(defaulted.get("value"), defaulted.get("default"));+        assert_eq!(defaulted.get("value_source"), Some(&json!("default")));+        assert_eq!(defaulted.get("steps"), Some(&Value::Null));+        assert_eq!(defaulted.get("values"), Some(&json!([])));+    }++    #[test]+    fn built_in_instrument_is_listed_through_the_instrument_slot() {+        let mut e = Engine::default();+        let track = new_track(&mut e, Some("instrument"));+        let result = e.execute(CMD, &json!({"track": track, "slot": "instrument"})).unwrap();+        let info = soundcraft_dsp::plugin_info("subtractive_synth").unwrap();+        assert_eq!(result.get("plugin"), Some(&json!("subtractive_synth")));+        assert_eq!(result.get("total"), Some(&json!(info.params.len())));+        assert_eq!(listed_params(&result).len(), info.params.len().min(DEFAULT_LIMIT));+    }++    #[test]+    fn filter_and_pagination_report_the_filtered_total() {+        let mut e = Engine::default();+        let track = insert_eq(&mut e);+        let all = e.execute(CMD, &json!({"track": track, "slot": 0, "filter": "FrEq", "limit": 1000})).unwrap();+        let expected: Vec<_> = soundcraft_dsp::plugin_info("eq_7band")+            .unwrap()+            .params+            .iter()+            .filter(|param| param.id.to_ascii_lowercase().contains("freq") || param.name.to_ascii_lowercase().contains("freq"))+            .collect();+        assert_eq!(all.get("total"), Some(&json!(expected.len())));++        let page = e.execute(CMD, &json!({"track": track, "slot": 0, "filter": "fReQ", "offset": 1, "limit": 2})).unwrap();+        assert_eq!(page.get("total"), Some(&json!(expected.len())));+        assert_eq!(page.get("offset"), Some(&json!(1)));+        assert_eq!(listed_params(&page).len(), expected.len().saturating_sub(1).min(2));+        assert_eq!(listed_params(&page).first().and_then(|param| param.get("id")).and_then(Value::as_str), expected.get(1).map(|param| param.id));++        let past_end = e.execute(CMD, &json!({"track": track, "slot": 0, "filter": "freq", "offset": 10_000})).unwrap();+        assert_eq!(past_end.get("total"), Some(&json!(expected.len())));+        assert!(listed_params(&past_end).is_empty());+    }++    #[test]+    fn filter_uses_unicode_lowercase_and_rejects_more_than_200_characters() {+        let param =+            ParamInfo {+                id: "größe", name: "Résonance Über", min: 0.0, max: 1.0, default: 0.5, unit: Unit::None, taper: Taper::Linear, choices: &[]+            };+        assert!(matches_filter(&param, &"RÉSONANCE".to_lowercase()));+        assert!(matches_filter(&param, &"ÜBER".to_lowercase()));++        let mut e = Engine::default();+        let track = insert_eq(&mut e);+        let too_long = "é".repeat(201);+        let err = e.execute(CMD, &json!({"track": track, "slot": 0, "filter": too_long})).unwrap_err();+        assert!(err.to_string().contains("at most 200 characters"), "{err}");+    }++    #[test]+    fn pagination_defaults_to_200_and_rejects_null_and_numeric_strings() {+        assert_eq!(pagination(&json!({})), Ok((0, DEFAULT_LIMIT)));+        for params in [json!({"offset": null}), json!({"offset": "2"}), json!({"limit": null}), json!({"limit": "200"})] {+            assert!(pagination(&params).is_err(), "accepted {params}");+        }+    }++    #[test]+    fn non_finite_metadata_and_values_are_null_without_dropping_the_parameter() {+        let param = ParamInfo {+            id: "hostile",+            name: "Hostile",+            min: f32::NEG_INFINITY,+            max: f32::INFINITY,+            default: f32::NAN,+            unit: Unit::None,+            taper: Taper::Linear,+            choices: &[],+        };+        let value = parameter_json(&param, &Insert::new("test"));+        assert_eq!(value.get("id"), Some(&json!("hostile")));+        for field in ["min", "max", "default", "value"] {+            assert_eq!(value.get(field), Some(&Value::Null), "{field}");+        }+    }++    #[test]+    fn hostile_pagination_and_filter_values_are_rejected() {+        let mut e = Engine::default();+        let track = insert_eq(&mut e);+        for params in [+            json!({"track": track, "slot": 0, "offset": -1}),+            json!({"track": track, "slot": 0, "offset": 1.5}),+            json!({"track": track, "slot": 0, "limit": -1}),+            json!({"track": track, "slot": 0, "limit": 1.5}),+            json!({"track": track, "slot": 0, "limit": 0}),+            json!({"track": track, "slot": 0, "limit": 1001}),+            json!({"track": track, "slot": 0, "filter": 7}),+            json!({"track": track, "slot": 0, "offset": null}),+            json!({"track": track, "slot": 0, "offset": "1"}),+            json!({"track": track, "slot": 0, "limit": null}),+            json!({"track": track, "slot": 0, "limit": "200"}),+        ] {+            assert!(e.execute(CMD, &params).is_err(), "accepted {params}");+        }+    }++    #[test]+    fn empty_instrument_unknown_plugin_and_unknown_slot_are_errors() {+        let mut e = Engine::default();+        let audio = new_track(&mut e, None);+        assert!(e.execute(CMD, &json!({"track": audio, "slot": 0})).unwrap_err().to_string().contains("empty slot"));+        assert!(e.execute(CMD, &json!({"track": audio, "slot": "instrument"})).unwrap_err().to_string().contains("the track has no instrument"));+        assert!(e.execute(CMD, &json!({"track": audio, "slot": "unknown"})).is_err());++        if let Some(track) = e.session_mut().track_mut(soundcraft_model::TrackId(audio)) {+            track.mixer.inserts[0] = Some(Insert::new("not_a_plugin"));+        }+        assert!(e.execute(CMD, &json!({"track": audio, "slot": 0})).unwrap_err().to_string().contains("unknown plugin"));+    }++    #[test]+    fn choice_and_toggle_parameters_include_steps_and_values() {+        let mut e = Engine::default();+        let track = insert_eq(&mut e);+        let result = e.execute(CMD, &json!({"track": track, "slot": 0, "limit": 1000})).unwrap();+        let params = listed_params(&result);+        let toggle = params.iter().find(|param| param.get("id") == Some(&json!("hpf_on"))).unwrap();+        assert_eq!(toggle.get("steps"), Some(&json!(2)));+        assert_eq!(toggle.get("values"), Some(&json!(["Off", "On"])));+        let choice = params.iter().find(|param| param.get("id") == Some(&json!("hpf_slope"))).unwrap();+        assert_eq!(choice.get("steps"), Some(&json!(4)));+        assert_eq!(choice.get("values"), Some(&json!(["6 dB/oct", "12 dB/oct", "18 dB/oct", "24 dB/oct"])));+    }++    #[test]+    fn query_does_not_change_revision_undo_history_or_journal() {+        let mut e = Engine::default();+        let track = insert_eq(&mut e);+        let revision = e.revision;+        let undo = e.undo_history();+        let journal = e.journal.clone();+        e.mark_clean();+        let dirty = e.is_dirty();+        e.execute(CMD, &json!({"track": track, "slot": 0})).unwrap();+        assert_eq!(e.revision, revision);+        assert_eq!(e.undo_history(), undo);+        assert_eq!(e.journal, journal);+        assert_eq!(e.is_dirty(), dirty);+    }++    #[cfg(target_os = "macos")]+    #[test]+    fn apple_dls_instrument_lists_numeric_parameter_ids() {+        let mut e = Engine::default();+        e.execute("track.new", &json!({"kind": "instrument", "instrument": "au:aumu:dls :appl"})).unwrap();+        let track = e.session().tracks[0].id.0;+        let result = e.execute(CMD, &json!({"track": track, "slot": "instrument", "limit": 1000})).unwrap();+        let params = listed_params(&result);+        assert!(!params.is_empty());+        assert!(params.iter().all(|param| param.get("id").and_then(Value::as_str).and_then(|id| id.parse::<u32>().ok()).is_some()));+    }+}diff --git a/crates/engine/src/cmd/plugin_state.rs b/crates/engine/src/cmd/plugin_state.rs--- a/crates/engine/src/cmd/plugin_state.rs+++ b/crates/engine/src/cmd/plugin_state.rs@@ -13,7 +13,7 @@ pub fn specs() -> Vec<CommandSpec> {         label: "Store Plugin State",         menu: &[],         shortcut: None,-        params: "{track, slot: 0..9 | \"a\"..\"j\" | \"instrument\", state: base64 | null} → {bytes}. Not undoable and not journaled (states can be large).",+        params: "{track, slot: 0..9 | \"a\"..\"j\" | \"instrument\", state: base64 | null, params?: {id: number}} → {bytes}. The state is not undoable and not journaled (states can be large). `params`, when a value changes, is stored in that same edit as one undo step and is not journaled.",         enabled: has_tracks,         run: insert_state,         journal: false,@@ -40,6 +40,62 @@ fn slot_param(p: &Value) -> Result<Option<usize>> {     } } +fn stored_insert(e: &Engine, track: soundcraft_model::TrackId, slot: Option<usize>) -> Option<&soundcraft_model::Insert> {+    let tr = e.session().track(track)?;+    match slot {+        Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),+        None => tr.instrument.as_ref(),+    }+}++fn insert_mut(s: &mut soundcraft_model::Session, track: soundcraft_model::TrackId, slot: Option<usize>) -> Option<&mut soundcraft_model::Insert> {+    let tr = s.track_mut(track)?;+    match slot {+        Some(i) => tr.mixer.inserts.get_mut(i).and_then(Option::as_mut),+        None => tr.instrument.as_mut(),+    }+}++fn state_matches(ins: &soundcraft_model::Insert, state: Option<&[u8]>) -> bool {+    ins.state_bytes().as_deref() == state.filter(|b| !b.is_empty())+}++/// Optional `params` on `mix.insert_state`. Absent or empty stores nothing. Keys are parameter+/// ids; values are finite numbers, clamped the same way as `mix.insert_params`.+fn param_writes(e: &Engine, track: soundcraft_model::TrackId, slot: Option<usize>, p: &Value) -> Result<Vec<(String, f32)>> {+    let Some(raw) = p.get("params") else { return Ok(Vec::new()) };+    let obj = raw.as_object().ok_or_else(|| bad(CMD, "`params` must be an object"))?;+    if obj.is_empty() {+        return Ok(Vec::new());+    }+    let ins = stored_insert(e, track, slot).ok_or_else(|| bad(CMD, "empty slot"))?;+    let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad(CMD, "unknown plugin"))?;+    let mut unknown = Vec::new();+    let mut non_numeric = Vec::new();+    for (k, v) in obj {+        if !info.params.iter().any(|x| x.id == k.as_str()) {+            unknown.push(k.as_str());+        } else if v.as_f64().is_none_or(|f| !f.is_finite()) {+            non_numeric.push(k.as_str());+        }+    }+    if !unknown.is_empty() {+        unknown.sort_unstable();+        return Err(bad(CMD, format!("`{}` has no parameter(s): {}", info.id, unknown.join(", "))));+    }+    if !non_numeric.is_empty() {+        non_numeric.sort_unstable();+        return Err(bad(CMD, format!("parameter(s) need a numeric value: {}", non_numeric.join(", "))));+    }+    let mut out = Vec::with_capacity(obj.len());+    for (k, v) in obj {+        let Some(pi) = info.params.iter().find(|x| x.id == k.as_str()) else { continue };+        let Some(f) = v.as_f64().filter(|f| f.is_finite()) else { continue };+        out.push((k.clone(), pi.clamp(f as f32)));+    }+    Ok(out)+}+ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {     let t = track_param(e, CMD, p, "track")?.ok_or_else(|| bad(CMD, "`track` required"))?;     let slot = slot_param(p)?;@@ -49,24 +105,40 @@ fn insert_state(e: &mut Engine, p: &Value) -> Result<Value> {         Some(_) => return Err(bad(CMD, "`state` must be a base64 string or null")),     };     let n = state.as_ref().map_or(0, Vec::len);+    let writes = param_writes(e, t, slot, p)?;     // Plugins report changes that leave their state as it was: don't modify the session for those.-    let stored = e.session().track(t).and_then(|tr| match slot {-        Some(i) => tr.mixer.inserts.get(i).and_then(Option::as_ref),-        None => tr.instrument.as_ref(),-    });-    if stored.is_some_and(|ins| ins.state_bytes().as_deref() == state.as_deref().filter(|b| !b.is_empty())) {+    // A held parameter that actually moved is part of the same edit, so it does not publish a+    // second revision on its own.+    let (unchanged, params_changed) = {+        let Some(stored) = stored_insert(e, t, slot) else { return Err(bad(CMD, "empty slot")) };+        let params_changed = writes.iter().any(|(k, v)| stored.params.get(k).copied() != Some(*v));+        (state_matches(stored, state.as_deref()) && !params_changed, params_changed)+    };+    if unchanged {         return Ok(json!({"bytes": n}));     }-    let s = e.session_mut();-    let tr = s.track_mut(t).ok_or_else(|| bad(CMD, "no track"))?;-    let ins = match slot {-        Some(i) => tr.mixer.inserts.get_mut(i).and_then(Option::as_mut),-        None => tr.instrument.as_mut(),+    let undo = params_changed.then(|| e.session_arc());+    {+        let s = e.session_mut();+        let ins = insert_mut(s, t, slot).ok_or_else(|| bad(CMD, "empty slot"))?;+        for (k, v) in &writes {+            ins.params.insert(k.clone(), *v);+        }+        match &state {+            Some(bytes) => ins.set_state_bytes(bytes),+            None => ins.state = None,+        }     }-    .ok_or_else(|| bad(CMD, "empty slot"))?;-    match state {-        Some(bytes) => ins.set_state_bytes(&bytes),-        None => ins.state = None,+    if let Some(mut before) = undo {+        // State is not undoable. The snapshot was taken before this command, so it still has the+        // previous bytes; put the bytes just stored back, and let undo restore only the parameters.+        if let Some(ins) = insert_mut(std::sync::Arc::make_mut(&mut before), t, slot) {+            match &state {+                Some(bytes) => ins.set_state_bytes(bytes),+                None => ins.state = None,+            }+        }+        e.push_undo("Set Plugin Parameters", before);     }     Ok(json!({"bytes": n})) }@@ -105,6 +177,69 @@ mod tests {         assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None);     } +    #[test]+    fn held_params_share_the_state_revision_and_one_undo_step() {+        let mut e = Engine::default();+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();+        let t = e.session().tracks[0].id.0;+        let rev = e.revision;+        let undo = e.undo_history().len();+        let blob = b64::encode(&[9, 1]);+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": blob, "params": {"cutoff": 44.0}})).unwrap();+        assert_eq!(e.revision, rev.wrapping_add(1), "the parameter and the state are one document revision");+        assert_eq!(e.undo_history().len(), undo + 1);+        assert_eq!(e.undo_history().last().map(String::as_str), Some("Set Plugin Parameters"));+        let ins = e.session().tracks[0].instrument.clone().unwrap();+        assert_eq!(ins.params.get("cutoff").copied(), Some(44.0));+        assert_eq!(ins.state_bytes().as_deref(), Some(&[9, 1][..]));+        assert!(e.journal.iter().all(|(id, _)| id != "mix.insert_state"), "not journaled");+        // The same bytes and the same value publish nothing more.+        let rev = e.revision;+        let undo = e.undo_history().len();+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": blob, "params": {"cutoff": 44.0}})).unwrap();+        assert_eq!(e.revision, rev);+        assert_eq!(e.undo_history().len(), undo);+    }++    #[test]+    fn undo_restores_parameters_and_keeps_the_new_state() {+        let mut e = Engine::default();+        e.execute("track.new", &json!({"kind": "instrument"})).unwrap();+        let t = e.session().tracks[0].id.0;+        let old_state = b64::encode(b"old-state");+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": old_state})).unwrap();+        e.execute("mix.instrument_param", &json!({"track": t, "param": "cutoff", "value": 1000.0})).unwrap();+        let new_state = b64::encode(b"new-state");+        e.execute("mix.insert_state", &json!({"track": t, "slot": "instrument", "state": new_state, "params": {"cutoff": 44.0}})).unwrap();+        let ins = e.session().tracks[0].instrument.clone().unwrap();+        assert_eq!(ins.params.get("cutoff").copied(), Some(44.0));+        assert_eq!(ins.state_bytes().as_deref(), Some(&b"new-state"[..]));+        assert!(e.undo());+        let ins = e.session().tracks[0].instrument.clone().unwrap();+        assert_eq!(ins.params.get("cutoff").copied(), Some(1000.0), "undo restores the parameter");+        assert_eq!(ins.state_bytes().as_deref(), Some(&b"new-state"[..]), "undo keeps the new state");+        assert!(e.redo());+        let ins = e.session().tracks[0].instrument.clone().unwrap();+        assert_eq!(ins.params.get("cutoff").copied(), Some(44.0));+        assert_eq!(ins.state_bytes().as_deref(), Some(&b"new-state"[..]));++        // Clearing the state is the new state: undo restores the parameter and leaves it cleared.+        e.execute("track.new", &json!({})).unwrap();+        let audio = e.session().tracks.last().unwrap().id;+        let a = audio.0;+        e.execute("mix.insert", &json!({"track": a, "plugin": "gain", "slot": 0})).unwrap();+        let blob = b64::encode(b"gain-state");+        e.execute("mix.insert_state", &json!({"track": a, "slot": 0, "state": blob})).unwrap();+        e.execute("mix.insert_param", &json!({"track": a, "slot": 0, "param": "gain", "value": -6.0})).unwrap();+        let before = e.session().track(audio).unwrap().mixer.inserts[0].as_ref().unwrap().params.get("gain").copied();+        e.execute("mix.insert_state", &json!({"track": a, "slot": 0, "state": null, "params": {"gain": -12.0}})).unwrap();+        assert_eq!(e.session().track(audio).unwrap().mixer.inserts[0].as_ref().unwrap().state, None);+        assert!(e.undo());+        let ins = e.session().track(audio).unwrap().mixer.inserts[0].as_ref().unwrap();+        assert_eq!(ins.params.get("gain").copied(), before);+        assert_eq!(ins.state, None, "undo does not bring the cleared state back");+    }+     #[test]     fn storing_the_same_state_again_changes_nothing() {         let mut e = Engine::default();diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs--- a/crates/mix/src/lib.rs+++ b/crates/mix/src/lib.rs@@ -54,7 +54,8 @@ use soundcraft_dsp::pan::{PanLaw, SpeakerPos, SurroundParams, gains}; use soundcraft_dsp::{Plugin, SampleSource, Transport, db_to_gain}; use soundcraft_midi::CtrlKind; use soundcraft_model::{-    AutoParam, BusId, ChannelFormat, Clip, ClipContent, Route, Session, SourceAudio, Speaker, SurroundPan, Track, TrackId, TrackKind,+    AutoParam, AutomationLane, BusId, ChannelFormat, Clip, ClipContent, INSTRUMENT_AUTO_SLOT, Route, Session, SourceAudio, Speaker, SurroundPan,+    Track, TrackId, TrackKind, }; use soundcraft_time::{Range, Samples, TICKS_PER_QUARTER}; use std::collections::HashMap;@@ -245,6 +246,30 @@ struct BusBuf {     buf: Vec<Vec<f32>>, } +/// One unstored parameter an automation lane is driving.+///+/// `param` is an index into `plugin.info().params` (those ids are `'static`, so restoring does+/// not keep the session's `String`). `lane` is that string's address. `last` is the lane target+/// last sent, so a steady lane does not write again. `settled` is the value the plugin reported+/// after that write: a host may clamp or round, and an editor edit is a move away from that+/// settled value, not away from the raw lane target. `original` is the plugin's value from before+/// the lane took over; nothing in the session stores it.+struct LaneHold {+    param: usize,+    lane: usize,+    last: f32,+    settled: f32,+    original: f32,+}++/// Unstored automated parameters a new slot can take before the holds vector has to grow.+/// Growing past this happens once, on the block a further parameter comes under automation.+const UNSTORED_HOLD_CAP: usize = 8;++/// Absolute and relative tolerance for "the plugin already has the lane's value". A plugin that+/// quantizes can sit a hair off what automation sent; an editor move is far larger.+const PARAM_MATCH_TOLERANCE: f32 = 1.0e-6;+ struct PluginSlot {     id: String,     /// Channel count the plugin was prepared for.@@ -252,9 +277,19 @@ struct PluginSlot {     plugin: Box<dyn Plugin>,     /// Last values pushed to the plugin, in the insert's parameter-map order.     applied: Vec<f32>,+    /// Unstored parameters currently under automation. Looked up by parameter index and lane+    /// identity, not by position. Reserved for [`UNSTORED_HOLD_CAP`] at creation; it grows only+    /// when a new hold is pushed.+    holds: Vec<LaneHold>,     failed: bool, } +impl PluginSlot {+    fn new(id: String, ch: usize, plugin: Box<dyn Plugin>) -> Self {+        PluginSlot { id, ch, plugin, applied: Vec::new(), holds: Vec::with_capacity(UNSTORED_HOLD_CAP), failed: false }+    }+}+ struct Strip {     buf: Vec<Vec<f32>>,     /// Pre-fader copy for pre-fader sends.@@ -476,7 +511,7 @@ impl SlotCtx<'_> {             let state = if third { ins.state_bytes() } else { None };             create_instance(&ins.plugin, state.as_deref(), self.sr, self.mb, ch).map(|n| n.plugin)         }?;-        Some(PluginSlot { id: ins.plugin.clone(), ch, plugin, applied: Vec::new(), failed: false })+        Some(PluginSlot::new(ins.plugin.clone(), ch, plugin))     } } @@ -1870,22 +1905,171 @@ fn note_insert_latency(t: &Track, strip: &mut Strip, pos: Samples) { }  fn apply_params(slot: &mut PluginSlot, ins: &soundcraft_model::Insert, t: &Track, i: usize, pos: Samples) {-    // Automation lanes address insert slots; the instrument (`INSTRUMENT_SLOT`) has none, so it-    // must not pick up slot A's lanes.-    let Ok(slot_u8) = u8::try_from(i) else {+    // Automation lanes address insert slots by index and the instrument (`INSTRUMENT_SLOT`) by+    // `INSTRUMENT_AUTO_SLOT`, so the instrument never picks up slot A's lanes.+    let slot_u8 = match u8::try_from(i) {+        _ if i == INSTRUMENT_SLOT => INSTRUMENT_AUTO_SLOT,+        Ok(s) => s,+        Err(_) => {+            // This call is not driving the slot, so a lane that was driving it has let go.+            release_inactive_holds(slot, |_| false);+            return apply_values(slot, ins, |_, v| v);+        }+    };+    let reads = t.mixer.automation_mode.reads();+    let lanes = || {+        t.automation.iter().filter_map(move |l| match &l.param {+            AutoParam::Plugin { slot, param } if reads && *slot == slot_u8 && !l.points.is_empty() => Some((param.as_str(), l)),+            _ => None,+        })+    };+    // Parameters the insert stores go through `apply_values`. The rest live in the plugin (a+    // hosted plug-in's state blob) and are tracked in `slot.holds`.+    let unstored = || lanes().filter(|(name, _)| !ins.params.contains_key(*name));+    if lanes().next().is_none() {+        // No lane is reading this slot: automation is off, or the lanes are gone. Put unstored+        // parameters back; the session has no value of its own to restore.+        release_inactive_holds(slot, |_| false);         return apply_values(slot, ins, |_, v| v);+    }+    if !slot.holds.is_empty() {+        // A rebuilt session allocates a new parameter `String` for the same lane. Retarget the+        // hold before releasing, or the lane would look gone and the value it took over from+        // would be read back as a fresh original.+        rebind_unstored_holds(slot, unstored());+        let info = slot.plugin.info();+        release_inactive_holds(slot, |hold| unstored().any(|(name, _)| hold_is(info, hold, name)));+    }+    apply_values(slot, ins, |k, val| lanes().find(|(p, _)| *p == k).map_or(val, |(_, l)| l.value_at(pos, val)));+    for (name, lane) in unstored() {+        drive_unstored_lane(slot, name, lane, pos);+    }+}++/// Address of the session's parameter id. A new `String` with the same text is a different lane+/// identity until [`rebind_unstored_holds`] matches it to the parameter index.+fn lane_identity(param: &str) -> usize {+    param.as_ptr() as usize+}++fn plugin_param_index(slot: &PluginSlot, id: &str) -> Option<usize> {+    slot.plugin.info().params.iter().position(|p| p.id == id)+}++fn plugin_param_id(slot: &PluginSlot, index: usize) -> Option<&'static str> {+    slot.plugin.info().params.get(index).map(|p| p.id)+}++/// Plugin value to put back when the lane lets go. The live value wins; the parameter's default+/// covers a plugin that does not report it. Non-finite values are not a value we can restore.+fn read_original(slot: &PluginSlot, id: &str, index: usize) -> f32 {+    let reported = slot.plugin.param(id).filter(|v| v.is_finite());+    if let Some(v) = reported {+        return v;+    }+    slot.plugin.info().params.get(index).map(|p| p.default).filter(|v| v.is_finite()).unwrap_or(0.0)+}++fn live_differs_from_lane(live: f32, lane: f32) -> bool {+    if live.to_bits() == lane.to_bits() {+        return false;+    }+    let diff = (live - lane).abs();+    if !diff.is_finite() {+        return true;+    }+    let scale = live.abs().max(lane.abs());+    diff > PARAM_MATCH_TOLERANCE * (1.0 + scale)+}++/// Whether `hold` is the hold of lane `name`: the same lane identity AND the same parameter. An+/// address alone is not enough: the allocator can reuse a dropped lane's address for a lane on+/// another parameter.+fn hold_is(info: &soundcraft_dsp::PluginInfo, hold: &LaneHold, name: &str) -> bool {+    hold.lane == lane_identity(name) && info.params.get(hold.param).is_some_and(|p| p.id == name)+}++/// Point an existing hold at a lane whose `String` was rebuilt, matching the parameter id to the+/// cached index. The parameter table is scanned only then, not while the identity is unchanged.+fn rebind_unstored_holds<'a>(slot: &mut PluginSlot, lanes: impl Iterator<Item = (&'a str, &'a AutomationLane)>) {+    let info = slot.plugin.info();+    for (name, _) in lanes {+        let id = lane_identity(name);+        if slot.holds.iter().any(|h| hold_is(info, h, name)) {+            continue;+        }+        let Some(param) = plugin_param_index(slot, name) else { continue };+        if let Some(hold) = slot.holds.iter_mut().find(|h| h.param == param) {+            hold.lane = id;+        }+    }+}++fn release_inactive_holds(slot: &mut PluginSlot, mut still_active: impl FnMut(&LaneHold) -> bool) {+    let mut i = 0;+    while i < slot.holds.len() {+        let active = match slot.holds.get(i) {+            Some(hold) => still_active(hold),+            None => break,+        };+        if active {+            i += 1;+            continue;+        }+        restore_hold(slot, i);+        slot.holds.swap_remove(i);+    }+}++fn restore_hold(slot: &mut PluginSlot, index: usize) {+    let Some((param, original)) = slot.holds.get(index).map(|h| (h.param, h.original)) else { return };+    if let Some(id) = plugin_param_id(slot, param) {+        slot.plugin.set_param(id, original);+    }+}++fn drive_unstored_lane(slot: &mut PluginSlot, name: &str, lane: &AutomationLane, pos: Samples) {+    let id = lane_identity(name);+    let info = slot.plugin.info();+    let index = if let Some(i) = slot.holds.iter().position(|h| hold_is(info, h, name)) {+        i+    } else if let Some(param) = plugin_param_index(slot, name) {+        if let Some(i) = slot.holds.iter().position(|h| h.param == param) {+            if let Some(hold) = slot.holds.get_mut(i) {+                hold.lane = id;+            }+            i+        } else {+            // Read the plugin before the first write, so the lane can give the parameter back.+            let original = read_original(slot, name, param);+            slot.holds.push(LaneHold { param, lane: id, last: f32::NAN, settled: f32::NAN, original });+            match slot.holds.len().checked_sub(1) {+                Some(i) => i,+                None => return,+            }+        }+    } else {+        return;     };-    let automated = t.mixer.automation_mode.reads()-        && t.automation.iter().any(|l| matches!(&l.param, AutoParam::Plugin { slot, .. } if *slot == slot_u8) && !l.points.is_empty());-    apply_values(slot, ins, |k, val| {-        if automated-            && let Some(l) = t.automation.iter().find(|l| matches!(&l.param, AutoParam::Plugin { slot, param } if *slot == slot_u8 && param == k))-            && !l.points.is_empty()-        {-            return l.value_at(pos, val);+    drive_hold(slot, index, lane, pos);+}++fn drive_hold(slot: &mut PluginSlot, index: usize, lane: &AutomationLane, pos: Samples) {+    let Some((param, original, last, settled)) = slot.holds.get(index).map(|h| (h.param, h.original, h.last, h.settled)) else { return };+    let value = lane.value_at(pos, original);+    let Some(id) = plugin_param_id(slot, param) else { return };+    let changed = value.to_bits() != last.to_bits();+    // An editor edit moves the live value off what the plugin settled on. Comparing with the+    // raw lane target would stay true forever when the plugin clamps or rounds the write.+    let diverged = slot.plugin.param(id).is_some_and(|live| live_differs_from_lane(live, settled));+    if changed || diverged {+        slot.plugin.set_param(id, value);+        let settled_now = slot.plugin.param(id).unwrap_or(value);+        if let Some(hold) = slot.holds.get_mut(index) {+            hold.last = value;+            hold.settled = settled_now;         }-        val-    });+    } }  /// Sends each stored param (as mapped by `value`) to the plugin when it changed since last time.@@ -2507,7 +2691,7 @@ mod tests {      use super::*;     use soundcraft_audio_io::AudioBuffer;-    use soundcraft_model::{Insert, SourceAudio, SourceId};+    use soundcraft_model::{AutomationMode, Insert, SourceAudio, SourceId};     use std::sync::Arc;      fn synth_session(level_db: f32) -> Session {@@ -2564,6 +2748,259 @@ mod tests {         assert!((quiet / loud - 0.5).abs() < 0.05, "-12 dB vs -6 dB should halve the level: {quiet} / {loud}");     } +    #[test]+    fn instrument_automation_drives_the_instrument() {+        let quiet = render_range(&synth_session(-48.0), Range::new(0, 12_000), 512);+        let mut s = synth_session(-6.0);+        let tr = &mut s.tracks[0];+        let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "level".into() });+        lane.set_point(0, -48.0);+        tr.automation.push(lane);+        assert_eq!(render_range(&s, Range::new(0, 12_000), 512), quiet, "the lane overrides the stored -6 dB");+    }++    #[test]+    fn automation_drives_a_parameter_the_session_holds_no_value_for() {+        // A hosted instrument made by `track.new` starts with an empty parameter map.+        let quiet = render_range(&synth_session(-48.0), Range::new(0, 12_000), 512);+        let mut s = synth_session(-6.0);+        let tr = &mut s.tracks[0];+        if let Some(ins) = tr.instrument.as_mut() {+            ins.params.clear();+        }+        let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "level".into() });+        lane.set_point(0, -48.0);+        tr.automation.push(lane);+        assert_eq!(render_range(&s, Range::new(0, 12_000), 512), quiet);+    }++    /// Built-in synth with an empty parameter map, the hosted-instrument case: values live on the+    /// plugin until a lane takes them.+    fn unstored_synth() -> Option<(PluginSlot, Insert, Session, TrackId)> {+        let plugin = create_plugin("subtractive_synth")?;+        let slot = PluginSlot::new("subtractive_synth".into(), 2, plugin);+        let ins = Insert::new("subtractive_synth");+        let mut session = Session::default();+        let track = session.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);+        Some((slot, ins, session, track))+    }++    fn level_lane(value: f32) -> soundcraft_model::AutomationLane {+        let mut lane = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "level".into() });+        lane.set_point(0, value);+        lane+    }++    #[test]+    fn a_new_unstored_lane_in_the_place_of_another_is_sent() {+        // The cache of lane values is per parameter: a different parameter whose value happens to+        // equal the previous one's must still reach the plugin.+        let Some((mut slot, ins, mut s, t)) = unstored_synth() else { return };+        let lane = |param: &str| {+            let mut l = soundcraft_model::AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: param.into() });+            l.set_point(0, -30.0);+            l+        };+        let tr = s.track_mut(t).unwrap();+        tr.automation = vec![lane("level")];+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        assert_eq!(slot.plugin.param("level"), Some(-30.0));+        let cutoff = slot.plugin.param("cutoff");+        tr.automation = vec![lane("cutoff")];+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        assert_ne!(slot.plugin.param("cutoff"), cutoff, "the cutoff lane reached the plugin");+    }++    #[test]+    fn a_reused_lane_address_on_another_parameter_is_not_the_old_hold() {+        // The allocator may put a new lane's parameter string where a dropped lane's was. Simulate+        // that: a `level` hold whose lane identity is the address of a new `cutoff` lane.+        let Some(plugin) = create_plugin("subtractive_synth") else { return };+        let mut slot = PluginSlot::new("subtractive_synth".into(), 2, plugin);+        let ins = Insert::new("subtractive_synth");+        let mut s = Session::default();+        let t = s.add_track(TrackKind::Instrument, ChannelFormat::Stereo, None);+        let lane = |param: &str, v: f32| {+            let mut l = AutomationLane::new(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: param.into() });+            l.set_point(0, v);+            l+        };+        let level = slot.plugin.param("level").unwrap();+        let cutoff = slot.plugin.param("cutoff").unwrap();+        let tr = s.track_mut(t).unwrap();+        tr.automation = vec![lane("level", -30.0)];+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        assert_eq!(slot.plugin.param("level"), Some(-30.0));+        tr.automation = vec![lane("cutoff", 300.0)];+        if let (Some(AutoParam::Plugin { param, .. }), Some(hold)) = (tr.automation.first().map(|l| &l.param), slot.holds.first_mut()) {+            hold.lane = lane_identity(param);+        }+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        assert_eq!(slot.plugin.param("level"), Some(level), "level is given back");+        assert_eq!(slot.plugin.param("cutoff"), Some(300.0), "cutoff follows its own lane");+        assert_ne!(cutoff, 300.0);+    }++    #[test]+    fn an_unstored_lane_regains_control_after_an_editor_change() {+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };+        {+            let tr = session.track_mut(track).unwrap();+            tr.automation = vec![level_lane(-30.0)];+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.plugin.param("level"), Some(-30.0));+        // The plug-in editor moves the parameter while the lane stays at -30.+        slot.plugin.set_param("level", -5.0);+        assert_eq!(slot.plugin.param("level"), Some(-5.0));+        let tr = session.track_mut(track).unwrap();+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        assert_eq!(slot.plugin.param("level"), Some(-30.0));+    }++    /// Rounds every write to an integer and counts `set_param` calls.+    struct Rounding {+        inner: Box<dyn Plugin>,+        value: f32,+        sets: Arc<std::sync::atomic::AtomicUsize>,+    }++    impl Plugin for Rounding {+        fn info(&self) -> &'static soundcraft_dsp::PluginInfo {+            self.inner.info()+        }+        fn prepare(&mut self, sr: f32, mb: usize, ch: usize) {+            self.inner.prepare(sr, mb, ch);+        }+        fn reset(&mut self) {+            self.inner.reset();+        }+        fn set_param(&mut self, id: &str, v: f32) -> bool {+            if self.inner.info().param(id).is_none() {+                return false;+            }+            self.sets.fetch_add(1, std::sync::atomic::Ordering::Relaxed);+            self.value = v.round();+            true+        }+        fn param(&self, id: &str) -> Option<f32> {+            self.inner.info().param(id).map(|_| self.value)+        }+        fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {+            self.inner.process(io, frames);+        }+    }++    #[test]+    fn a_rounding_plugin_is_written_once_while_the_lane_holds_and_an_editor_edit_retakes_it() {+        let Some(inner) = create_plugin("gain") else { return };+        let sets = Arc::new(std::sync::atomic::AtomicUsize::new(0));+        let value = inner.param("gain").unwrap_or(0.0);+        let rounding = Rounding { inner, value, sets: Arc::clone(&sets) };+        let mut slot = PluginSlot::new("gain".into(), 1, Box::new(rounding));+        let ins = Insert::new("gain");+        let mut session = Session::default();+        let track = session.add_track(TrackKind::Audio, ChannelFormat::Mono, None);+        let mut lane = AutomationLane::new(AutoParam::Plugin { slot: 0, param: "gain".into() });+        // 0.4 rounds to 0, so the value the plugin settles on is not the lane target.+        lane.set_point(0, 0.4);+        {+            let tr = session.track_mut(track).unwrap();+            tr.automation = vec![lane];+            for _ in 0..32 {+                apply_params(&mut slot, &ins, tr, 0, 0);+            }+        }+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 1, "a constant lane writes once");+        assert_eq!(slot.plugin.param("gain"), Some(0.0));+        assert_eq!(slot.holds.first().map(|h| h.settled), Some(0.0));+        assert_eq!(slot.holds.first().map(|h| h.last), Some(0.4));++        // The editor moves the parameter. The lane target is unchanged, so only the live value+        // differs from what the plugin settled on.+        assert!(slot.plugin.set_param("gain", 3.2));+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 2);+        assert_eq!(slot.plugin.param("gain"), Some(3.0));+        let tr = session.track_mut(track).unwrap();+        apply_params(&mut slot, &ins, tr, 0, 0);+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 3, "the lane takes the parameter back");+        assert_eq!(slot.plugin.param("gain"), Some(0.0));+        for _ in 0..16 {+            apply_params(&mut slot, &ins, tr, 0, 0);+        }+        assert_eq!(sets.load(std::sync::atomic::Ordering::Relaxed), 3, "the retaken lane holds again");+    }++    #[test]+    fn removing_an_unstored_lane_restores_the_plugin_value() {+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };+        let original = slot.plugin.param("level");+        {+            let tr = session.track_mut(track).unwrap();+            tr.automation = vec![level_lane(-30.0)];+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.plugin.param("level"), Some(-30.0));+        let tr = session.track_mut(track).unwrap();+        tr.automation.clear();+        apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        assert_eq!(slot.plugin.param("level"), original);+        assert!(slot.holds.is_empty());+    }++    #[test]+    fn an_unstored_lane_restores_when_reading_stops_and_again_after_reenable() {+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };+        let original = slot.plugin.param("level");+        {+            let tr = session.track_mut(track).unwrap();+            tr.automation = vec![level_lane(-30.0)];+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.plugin.param("level"), Some(-30.0));+        {+            let tr = session.track_mut(track).unwrap();+            tr.mixer.automation_mode = AutomationMode::Off;+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.plugin.param("level"), original, "stopping read restores the pre-lane value");+        {+            let tr = session.track_mut(track).unwrap();+            tr.mixer.automation_mode = AutomationMode::Read;+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.plugin.param("level"), Some(-30.0), "the lane takes the parameter again");+        {+            let tr = session.track_mut(track).unwrap();+            tr.mixer.automation_mode = AutomationMode::Off;+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.plugin.param("level"), original, "a second stop restores again");+    }++    #[test]+    fn unstored_holds_do_not_grow_across_identical_blocks() {+        let Some((mut slot, ins, mut session, track)) = unstored_synth() else { return };+        let cap = slot.holds.capacity();+        assert!(cap >= UNSTORED_HOLD_CAP, "a new slot reserves room for a few unstored holds");+        {+            let tr = session.track_mut(track).unwrap();+            tr.automation = vec![level_lane(-30.0)];+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.holds.capacity(), cap, "the first hold fit in the reserved capacity");+        assert_eq!(slot.holds.len(), 1);+        let ptr = slot.holds.as_ptr();+        for _ in 0..100 {+            let tr = session.track_mut(track).unwrap();+            apply_params(&mut slot, &ins, tr, INSTRUMENT_SLOT, 0);+        }+        assert_eq!(slot.holds.capacity(), cap, "holds reallocated across identical blocks");+        assert_eq!(slot.holds.as_ptr(), ptr);+        assert_eq!(slot.holds.len(), 1);+        assert_eq!(slot.plugin.param("level"), Some(-30.0));+    }+     #[test]     fn insert_slot_a_automation_does_not_drive_the_instrument() {         let mut s = synth_session(-6.0);diff --git a/crates/model/src/automation.rs b/crates/model/src/automation.rs--- a/crates/model/src/automation.rs+++ b/crates/model/src/automation.rs@@ -58,13 +58,18 @@ pub enum AutoParam {     SendLevel(u8),     SendPan(u8),     SendMute(u8),-    /// Insert slot + parameter id.+    /// Insert slot + parameter id. Slot [`INSTRUMENT_AUTO_SLOT`] is the track's instrument;+    /// `u8::MAX` holds trim.     Plugin {         slot: u8,         param: String,     }, } +/// The [`AutoParam::Plugin`] slot of a track's instrument (`plugin:instrument:<id>`), past the+/// insert slots and next to trim's `u8::MAX`.+pub const INSTRUMENT_AUTO_SLOT: u8 = u8::MAX - 1;+ impl AutoParam {     pub fn label(&self) -> String {         match self {@@ -75,10 +80,12 @@ impl AutoParam {             AutoParam::SendLevel(i) => format!("send {} level", slot_letter(*i)),             AutoParam::SendPan(i) => format!("send {} pan", slot_letter(*i)),             AutoParam::SendMute(i) => format!("send {} mute", slot_letter(*i)),+            AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param } => format!("instrument {param}"),             AutoParam::Plugin { slot, param } => format!("insert {} {param}", slot_letter(*slot)),         }     }-    /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`.+    /// Parse ids like `volume`, `pan`, `pan2`, `mute`, `send_a_level`, `plugin:a:threshold`,+    /// `plugin:instrument:1575`.     pub fn parse(s: &str) -> Option<AutoParam> {         let s = s.trim().to_ascii_lowercase();         match s.as_str() {@@ -100,7 +107,8 @@ impl AutoParam {         }         if let Some(rest) = s.strip_prefix("plugin:") {             let (slot, param) = rest.split_once(':')?;-            return Some(AutoParam::Plugin { slot: letter_slot(slot)?, param: param.to_string() });+            let slot = if slot == "instrument" { INSTRUMENT_AUTO_SLOT } else { letter_slot(slot)? };+            return Some(AutoParam::Plugin { slot, param: param.to_string() });         }         None     }@@ -339,6 +347,10 @@ mod tests {     fn param_ids() {         assert_eq!(AutoParam::parse("send_b_level"), Some(AutoParam::SendLevel(1)));         assert_eq!(AutoParam::parse("plugin:a:threshold"), Some(AutoParam::Plugin { slot: 0, param: "threshold".into() }));+        let strum = AutoParam::parse("plugin:Instrument:1575");+        assert_eq!(strum, Some(AutoParam::Plugin { slot: INSTRUMENT_AUTO_SLOT, param: "1575".into() }));+        assert_eq!(strum.map(|p| p.label()).as_deref(), Some("instrument 1575"));+        assert_eq!(AutoParam::parse("plugin:instrument"), None);         assert_eq!(AutoParam::parse("nope"), None);         assert_eq!(AutomationMode::from_id("touch/latch"), Some(AutomationMode::TouchLatch));     }diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs--- a/crates/model/src/lib.rs+++ b/crates/model/src/lib.rs@@ -15,7 +15,7 @@ mod session; mod source; mod track; -pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint};+pub use automation::{AutoParam, AutomationLane, AutomationMode, AutomationPoint, INSTRUMENT_AUTO_SLOT}; pub use clip::{Clip, ClipContent, Fade, FadeShape}; pub use ids::{BusId, ClipId, GroupId, MarkerId, SourceId, TrackId}; pub use markers::{Group, MarkerKind, MemoryLocation};diff --git a/crates/model/src/track.rs b/crates/model/src/track.rs--- a/crates/model/src/track.rs+++ b/crates/model/src/track.rs@@ -1,6 +1,6 @@ //! Tracks and playlists. -use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, Mixer, TrackId};+use crate::{AutoParam, AutomationLane, ChannelFormat, Clip, ClipId, INSTRUMENT_AUTO_SLOT, Insert, Mixer, TrackId}; use soundcraft_time::{Range, Samples};  /// Track types (Track › New).@@ -348,6 +348,12 @@ impl Track {         self.playlist().map_or(&[], |p| p.clips.as_slice())     } +    /// The plugin an [`AutoParam::Plugin`] slot addresses: an insert, or the instrument+    /// ([`INSTRUMENT_AUTO_SLOT`]).+    pub fn plugin_at(&self, slot: u8) -> Option<&Insert> {+        if slot == INSTRUMENT_AUTO_SLOT { self.instrument.as_ref() } else { self.mixer.inserts.get(usize::from(slot)).and_then(Option::as_ref) }+    }+     pub fn lane(&self, p: &AutoParam) -> Option<&AutomationLane> {         self.automation.iter().find(|l| &l.param == p)     }diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs--- a/crates/ui-egui/src/edit_window.rs+++ b/crates/ui-egui/src/edit_window.rs@@ -2048,8 +2048,8 @@ fn breakpoint_at(s: &Session, tl: Rect, lane: Rect, track: &Track, p: &AutoParam  fn plugin_range(track: &Track, p: &AutoParam) -> (f32, f32) {     if let AutoParam::Plugin { slot, param } = p-        && let Some(Some(ins)) = track.mixer.inserts.get(usize::from(*slot))-        && let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin)+        && let Some(ins) = track.plugin_at(*slot)+        && let Some(info) = crate::mix_window::plugin_info(&ins.plugin)         && let Some(pi) = info.param(param)     {         return (pi.min, pi.max);@@ -2659,6 +2659,9 @@ fn automation_id(p: &AutoParam) -> String {  /// A slot as `AutoParam::parse` reads it: a letter for 0..=25, the number beyond that. fn slot_id(i: u8) -> String {+    if i == soundcraft_model::INSTRUMENT_AUTO_SLOT {+        return "instrument".into();+    }     if i < 26 { char::from(b'a' + i).to_string() } else { i.to_string() } } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -402,6 +402,10 @@ pub struct SoundApp {     /// Tests supply drained editor reports at the native polling boundary without an audio device.     #[cfg(test)]     editor_poll_fixture: Option<EditorPollFixture>,+    /// Instrument parameters changed in their editors that the session also holds (set by+    /// command): stored with the instrument's state (see `store_plugin_states`), or by+    /// `mix.insert_params` when a due capture returns no state.+    editor_instrument_params: Vec<(TrackId, String, f32)>,     pub frame_ms: f32,     last_frame: Option<f64>,     last_meter_poll: f64,@@ -444,21 +448,42 @@ fn editor_states_due( }  /// Collect one admitted editor poll. Both native hosting and the lifecycle test fixture use-/// this same gesture scheduler/state-capture route.+/// this same gesture scheduler/state-capture route. The third value is each due instrument track+/// whose capture returned no state (the plugin refused `getState()`, or it has no state reader). fn editor_changes(     pending: &mut Vec<(TrackId, usize)>,     edits: Vec<soundcraft_playback::EditorEdit>,     changed: Vec<(TrackId, usize)>,     closed: impl Fn((TrackId, usize)) -> bool,     capture: impl FnOnce(&[(TrackId, usize)]) -> soundcraft_playback::PluginStates,-) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates) {+) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates, Vec<TrackId>) {     let due = editor_states_due(pending, &edits, changed, closed);     let states = if due.is_empty() { Vec::new() } else { capture(&due) };-    (edits, states)+    let missed = instrument_tracks_without_state(&due, &states);+    (edits, states, missed)+}++/// Due instrument tracks that are absent from `states`. A capture that returns `None` omits the+/// slot, and the held parameters queued for that track still have to reach the session.+fn instrument_tracks_without_state(due: &[(TrackId, usize)], states: &soundcraft_playback::PluginStates) -> Vec<TrackId> {+    let mut missed = Vec::new();+    for &(track, slot) in due {+        if slot != soundcraft_mix::INSTRUMENT_SLOT {+            continue;+        }+        if states.iter().any(|(t, s, _)| *t == track && *s == slot) {+            continue;+        }+        if !missed.contains(&track) {+            missed.push(track);+        }+    }+    missed }  #[cfg(test)]-type EditorPollFixture = Box<dyn FnMut(&mut Vec<(TrackId, usize)>) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates)>;+type EditorPollFixture =+    Box<dyn FnMut(&mut Vec<(TrackId, usize)>) -> (Vec<soundcraft_playback::EditorEdit>, soundcraft_playback::PluginStates, Vec<TrackId>)>;  /// Whether another automation-write pass is due: the transport moved at least /// `step` since `last` in either direction. `last == i64::MIN` marks "never@@ -554,6 +579,7 @@ impl SoundApp {             editor_poll_due: true,             #[cfg(test)]             editor_poll_fixture: None,+            editor_instrument_params: Vec::new(),             frame_ms: 0.0,             last_frame: None,             last_meter_poll: f64::NEG_INFINITY,@@ -788,29 +814,87 @@ impl SoundApp {     }      /// Reads every live third-party plugin's state and stores it on its insert-    /// (`mix.insert_state`), so saves and bounces include it.+    /// (`mix.insert_state`), so saves and bounces include it. Held instrument parameters whose+    /// plugin returned no state are stored on their own (`mix.insert_params`), not dropped.     pub fn capture_plugin_states(&mut self) {-        let Some(p) = &self.player else { return };-        let states = p.capture_states();-        self.store_plugin_states(states);+        if let Some(p) = &self.player {+            let states = p.capture_states();+            self.store_plugin_states(states);+        }+        // Anything still queued belongs to a track the capture did not return. A save must not+        // write the session without those values, and `state: null` would clear a stored blob.+        let queued = self.queued_instrument_tracks();+        self.persist_queued_instrument_params(&queued);     }      fn store_plugin_states(&mut self, states: soundcraft_playback::PluginStates) {         for (t, slot, data) in states {-            let slot = if slot == soundcraft_mix::INSTRUMENT_SLOT { json!("instrument") } else { json!(slot) };-            let params = json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(&data)});-            // A state read while a knob is dragged (or a pass recorded) joins that gesture, so it-            // stays one undo step.-            let stored = match self.engine.merge_key().map(str::to_string) {-                Some(key) => self.engine.execute_merged("mix.insert_state", &params, &key),-                None => self.engine.execute("mix.insert_state", &params),-            };-            if let Err(e) = stored {-                log::warn!("storing plugin state: {e}");+            let instrument = slot == soundcraft_mix::INSTRUMENT_SLOT;+            let slot = if instrument { json!("instrument") } else { json!(slot) };+            let mut params = json!({"track": t.0, "slot": slot, "state": soundcraft_model::b64::encode(&data)});+            let mut edited = Vec::new();+            if instrument {+                let (taken, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.editor_instrument_params).into_iter().partition(|e| e.0 == t);+                self.editor_instrument_params = rest;+                if !taken.is_empty() {+                    // Same command as the state, so the gesture is one document revision. A value+                    // that moved is one undo step inside that command.+                    let values: serde_json::Map<String, Value> = taken.iter().map(|(_, k, v)| (k.clone(), json!(*v))).collect();+                    params["params"] = json!(values);+                }+                edited = taken;+            }+            if !self.execute_in_gesture("mix.insert_state", &params) {+                self.editor_instrument_params.extend(edited);+            }+        }+    }++    /// Tracks that still have held instrument parameters waiting to be stored.+    fn queued_instrument_tracks(&self) -> Vec<TrackId> {+        let mut tracks = Vec::new();+        for (t, _, _) in &self.editor_instrument_params {+            if !tracks.contains(t) {+                tracks.push(*t);+            }+        }+        tracks+    }++    /// Stores queued held parameters for `tracks` with `mix.insert_params` (slot `"instrument"`).+    /// A failed command puts the values back. Never sends `mix.insert_state` with a null state:+    /// that would clear the blob the plugin already has.+    fn persist_queued_instrument_params(&mut self, tracks: &[TrackId]) {+        for &t in tracks {+            let (edited, rest): (Vec<_>, Vec<_>) = std::mem::take(&mut self.editor_instrument_params).into_iter().partition(|e| e.0 == t);+            self.editor_instrument_params = rest;+            if edited.is_empty() {+                continue;+            }+            let values: serde_json::Map<String, Value> = edited.iter().map(|(_, k, v)| (k.clone(), json!(*v))).collect();+            let params = json!({"track": t.0, "slot": "instrument", "params": values});+            if !self.execute_in_gesture("mix.insert_params", &params) {+                self.editor_instrument_params.extend(edited);             }         }     } +    /// Runs a command that stores what a plugin editor changed. While a knob is dragged (or a pass+    /// recorded) it joins that gesture, so it stays one undo step. False when the command failed+    /// (the caller still holds whatever it took out of a queue).+    fn execute_in_gesture(&mut self, id: &str, params: &Value) -> bool {+        let stored = match self.engine.merge_key().map(str::to_string) {+            Some(key) => self.engine.execute_merged(id, params, &key),+            None => self.engine.execute(id, params),+        };+        if let Err(e) = stored {+            log::warn!("storing plugin state ({id}): {e}");+            false+        } else {+            true+        }+    }+     /// Opens the insert just picked from its menu: a hosted plugin's own editor, else (a built-in     /// plugin, no audio engine, or an editor that can't open here) its plugin window.     fn open_inserted(&mut self) {@@ -834,7 +918,7 @@ impl SoundApp {      /// Parameter edits made in plugin editors go into the session (one undo step per gesture).     /// Hosted instruments keep their parameters in their own state, which the session takes-    /// instead, as it does for plugins that said their state changed: either way the session is+    /// instead (a parameter the session also holds, set by command, is kept in step), as it does for plugins that said their state changed: either way the session is     /// modified, so it is autosaved and asks to be saved. The state is read once per gesture (see     /// `editor_states_due`).     fn apply_editor_edits(&mut self) {@@ -843,27 +927,45 @@ impl SoundApp {         }         #[cfg(test)]         if let Some(poll) = self.editor_poll_fixture.as_mut() {-            let (edits, states) = poll(&mut self.editor_states);-            self.store_editor_changes(edits, states);+            let (edits, states, missed) = poll(&mut self.editor_states);+            self.finish_editor_capture(edits, states, &missed);             return;         }         let Some(p) = &self.player else {+            // No live plugin left to read. The queued values are still edits the session holds.+            let queued = self.queued_instrument_tracks();+            self.persist_queued_instrument_params(&queued);             self.editor_states.clear();             return;         };-        let (edits, states) = editor_changes(+        let (edits, states, missed) = editor_changes(             &mut self.editor_states,             p.idle(),             p.changed_states(),             |(t, slot)| !p.editor_open(t, slot),             |due| p.capture_states_of(due),         );+        self.finish_editor_capture(edits, states, &missed);+    }++    /// Stores a finished capture. Due instrument tracks missing from `states` (the plugin returned+    /// no state) persist their queued parameters without touching the stored blob.+    fn finish_editor_capture(&mut self, edits: Vec<soundcraft_playback::EditorEdit>, states: soundcraft_playback::PluginStates, missed: &[TrackId]) {         self.store_editor_changes(edits, states);+        self.persist_queued_instrument_params(missed);     }      fn store_editor_changes(&mut self, edits: Vec<soundcraft_playback::EditorEdit>, states: soundcraft_playback::PluginStates) {         for (t, slot, param, value) in edits {             if slot == soundcraft_mix::INSTRUMENT_SLOT {+                // The instrument's state carries its edits. A value the session holds as well (set+                // by command) follows the editor when the state is stored, or it would undo the edit+                // on top of the state when the session is opened again.+                let held = self.engine.session().track(t).and_then(|tr| tr.instrument.as_ref()).is_some_and(|i| i.params.contains_key(&param));+                if held {+                    self.editor_instrument_params.retain(|(pt, pp, _)| (*pt, pp) != (t, &param));+                    self.editor_instrument_params.push((t, param, value));+                }                 continue;             }             let key = format!("param:{}:{slot}:{param}", t.0);@@ -872,10 +974,11 @@ impl SoundApp {         self.store_plugin_states(states);     } -    /// The session is modified, or a plugin editor gesture still holds a state the session hasn't-    /// taken (see `editor_states_due`): both read the plugins' states before autosaving.+    /// The session is modified, a plugin editor gesture still holds a state the session hasn't+    /// taken (see `editor_states_due`), or held instrument parameters are still queued outside+    /// the document. Closing must not discard that queue.     fn has_unsaved_changes(&self) -> bool {-        self.engine.is_dirty() || !self.editor_states.is_empty()+        self.engine.is_dirty() || !self.editor_states.is_empty() || !self.editor_instrument_params.is_empty()     }      /// Every two minutes, copy a modified session to the autosave folder (not while recording).@@ -2261,6 +2364,119 @@ mod tests {         assert_eq!(instrument_state(&app, keys), Some(vec![59, 1]));     } +    fn instrument_param(app: &SoundApp, t: TrackId, id: &str) -> Option<f32> {+        app.engine.session().track(t).and_then(|tr| tr.instrument.as_ref()).and_then(|i| i.params.get(id).copied())+    }++    #[test]+    fn an_instrument_parameter_the_session_holds_follows_its_editor_with_the_state() {+        let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+        let keys = app.engine.session().track_by_name("Keys").unwrap().id;+        app.engine.execute("mix.instrument_param", &json!({"track": keys.0, "param": "cutoff", "value": 1000})).unwrap();+        let steps = app.engine.undo_history().len();+        for now in 40..45 {+            editor_frame(&mut app, &[keys], &[], &[], now);+        }+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(1000.0), "mid-gesture the session is left alone");+        let revision = app.engine.revision;+        editor_frame(&mut app, &[], &[], &[], 44);+        assert_eq!(app.engine.revision, revision.wrapping_add(1), "the parameter and the state share one document revision");+        let cutoff = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).unwrap();+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(44f32.clamp(cutoff.min, cutoff.max)), "stored with the state");+        assert_eq!(instrument_state(&app, keys), Some(vec![44, 1]));+        assert_eq!(app.engine.undo_history().len(), steps + 1, "one undo step for the gesture");+        assert!(app.editor_instrument_params.is_empty());++        // A parameter the session doesn't hold stays in the instrument's state only.+        app.engine.session_mut().track_mut(keys).and_then(|tr| tr.instrument.as_mut()).unwrap().params.remove("cutoff");+        editor_frame(&mut app, &[keys], &[], &[], 50);+        editor_frame(&mut app, &[], &[], &[], 50);+        assert_eq!(instrument_param(&app, keys, "cutoff"), None);+        assert_eq!(instrument_state(&app, keys), Some(vec![50, 1]));+    }++    /// A hosted instrument whose `save_state` returns `None` (VST3 `getState` refused) still+    /// stores the parameters the session holds. The stored state blob is left as it was.+    #[test]+    fn held_instrument_params_persist_when_state_capture_returns_none() {+        use std::cell::Cell;+        use std::rc::Rc;++        let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+        let keys = app.engine.session().track_by_name("Keys").unwrap().id;+        let lead = app.engine.session().track_by_name("Lead").unwrap().id;+        app.engine.execute("mix.instrument_param", &json!({"track": keys.0, "param": "cutoff", "value": 1000})).unwrap();+        app.engine.execute("mix.instrument_param", &json!({"track": lead.0, "param": "cutoff", "value": 800})).unwrap();+        app.engine.session_mut().track_mut(keys).and_then(|tr| tr.instrument.as_mut()).unwrap().set_state_bytes(b"keep");+        let kept = instrument_state(&app, keys);+        let phase = Rc::new(Cell::new(0u8));+        let phase_poll = phase.clone();+        app.editor_poll_fixture = Some(Box::new(move |pending| {+            let n = phase_poll.get();+            phase_poll.set(n + 1);+            // Frame 0: both gestures are in progress. Frame 1: Keys ends, Lead continues.+            // Frame 2: Lead ends. The capture returns no state for whatever is due.+            let edits = match n {+                0 => vec![+                    (keys, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".to_string(), 44.0),+                    (lead, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".to_string(), 55.0),+                ],+                1 => vec![(lead, soundcraft_mix::INSTRUMENT_SLOT, "cutoff".to_string(), 56.0)],+                _ => Vec::new(),+            };+            super::editor_changes(pending, edits, Vec::new(), |_| false, |_due| Vec::new())+        }));+        let cutoff = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).unwrap();+        let clamped = |v: f32| v.clamp(cutoff.min, cutoff.max);++        app.editor_poll_due = true;+        app.apply_editor_edits();+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(1000.0), "mid-gesture the session is left alone");+        assert_eq!(instrument_param(&app, lead, "cutoff"), Some(800.0));+        assert_eq!(app.editor_instrument_params.len(), 2);+        assert!(app.has_unsaved_changes(), "queued parameters count as unsaved work");++        app.editor_poll_due = true;+        app.apply_editor_edits();+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(clamped(44.0)), "stored with mix.insert_params");+        assert_eq!(instrument_state(&app, keys), kept, "a missing state does not clear the stored bytes");+        assert_eq!(instrument_param(&app, lead, "cutoff"), Some(800.0), "a gesture still in progress stays queued");+        assert!(app.editor_instrument_params.iter().any(|e| e.0 == lead));+        assert!(!app.editor_instrument_params.iter().any(|e| e.0 == keys));++        app.editor_poll_due = true;+        app.apply_editor_edits();+        assert_eq!(instrument_param(&app, lead, "cutoff"), Some(clamped(56.0)));+        assert!(app.editor_instrument_params.is_empty(), "no queued entry is left behind");+    }++    /// Saving (or closing) with no live state reader must not drop parameters already queued.+    #[test]+    fn a_save_with_no_state_capture_keeps_queued_instrument_params() {+        let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());+        let keys = app.engine.session().track_by_name("Keys").unwrap().id;+        app.engine.execute("mix.instrument_param", &json!({"track": keys.0, "param": "cutoff", "value": 1000})).unwrap();+        app.engine.session_mut().track_mut(keys).and_then(|tr| tr.instrument.as_mut()).unwrap().set_state_bytes(b"keep");+        let kept = instrument_state(&app, keys);+        app.editor_instrument_params.push((keys, "cutoff".to_string(), 44.0));+        assert!(app.player.is_none(), "this capture has no hosted instrument to ask");+        app.capture_plugin_states();+        let cutoff = soundcraft_dsp::plugin_info("subtractive_synth").and_then(|i| i.param("cutoff")).unwrap();+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(44.0f32.clamp(cutoff.min, cutoff.max)));+        assert_eq!(instrument_state(&app, keys), kept);+        assert!(app.editor_instrument_params.is_empty());++        // The player going away mid-gesture takes the same path through the editor poll.+        app.editor_instrument_params.push((keys, "cutoff".to_string(), 70.0));+        app.editor_states.push((keys, soundcraft_mix::INSTRUMENT_SLOT));+        app.editor_poll_due = true;+        app.apply_editor_edits();+        assert_eq!(instrument_param(&app, keys, "cutoff"), Some(70.0f32.clamp(cutoff.min, cutoff.max)));+        assert!(app.editor_states.is_empty());+        assert!(app.editor_instrument_params.is_empty());+        assert_eq!(instrument_state(&app, keys), kept);+    }+     #[test]     fn two_instrument_editor_drags_store_its_state_twice() {         let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());@@ -3449,8 +3665,12 @@ mod tests {         assert_eq!(instrument_state(&app, keys), Some(vec![2, 1]));         assert_eq!(app.engine.revision, revision.wrapping_add(1), "the completed gesture persists exactly one document revision");         assert!(app.engine.is_dirty(), "captured instrument state is unsaved document work");-        assert_eq!(app.engine.undo_history().len(), undo, "state-only instrument persistence does not create an undo step");-        assert_eq!(app.engine.undo_history(), history, "state capture does not replace another history entry");+        // The demo instrument already holds cutoff, so the editor value is stored with the state+        // as one undo step. The blob itself is not journaled, and nothing earlier is replaced.+        let mut held = history.clone();+        held.push("Set Plugin Parameters".to_string());+        assert_eq!(app.engine.undo_history(), held, "a held parameter is one undo step with the state");+        assert_eq!(app.engine.undo_history().len(), undo + 1);         assert_eq!(app.engine.journal, journal, "plugin-state blobs are not journaled");         let settled_revision = app.engine.revision;         app.raw_input_hook(&ctx, &mut hidden);@@ -3458,8 +3678,7 @@ mod tests {         assert_eq!(polls.get(), 4, "a fresh empty host iteration is still live");         assert_eq!(captures.borrow().len(), 1, "the completed gesture is captured once");         assert_eq!(app.engine.revision, settled_revision);-        assert_eq!(app.engine.undo_history().len(), undo);-        assert_eq!(app.engine.undo_history(), history);+        assert_eq!(app.engine.undo_history(), held, "a later empty poll does not store the gesture again");         assert_eq!(app.engine.journal, journal);     } }diff --git a/docs/mcp.md b/docs/mcp.md--- a/docs/mcp.md+++ b/docs/mcp.md@@ -112,6 +112,7 @@ Resources: `soundcraft://session` (full session JSON), `soundcraft://commands`. - Programmatic calls never open dialogs; empty params act on the current selection. - Positions: samples, `{"seconds": x}`, or `"bars_beats:5|1|000"`-style strings. - Tracks by name or id; clips by id.+- Use `mix.plugin_params` through `execute` to discover parameter ids and metadata for an insert or instrument, including hosted plugins without an editor. - Every command is undoable through `edit.undo` unless it is a query or a view setting.  The acceptance test `crates/automation/tests/it/agent_tasks.rs` drives a realistic session end to enddiff --git a/docs/plugin-parity.md b/docs/plugin-parity.md--- a/docs/plugin-parity.md+++ b/docs/plugin-parity.md@@ -1,6 +1,6 @@ # Plugin parity: built-in processors and plugin hosting -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** trivial (`mix.plugin_params` parameter discovery; hosted instruments' parameters and automation by command (#1065); previously trivial: Auto Pan stereo balance; previously minor: AudioSuite speed ramps (speed_ramp); Auto Pan; Subharmonic; previously minor: Ring Modulator; VST3 host messages: IMessage and IAttributeList; previously trivial: Sampler ping-pong loop phase (#463); previously trivial: mix.instrument sets built-in instrument parameters in params (#265); previously minor: plugin editors on Windows) · **Target:** Avid Pro Tools Ultimate 2026.4.1  Pro Tools users live in plugins: the bundled set covers everyday mixing, and third-party plugins cover the rest. This doc tracks both. Our plugins have our own names and are written from@@ -40,7 +40,7 @@ User report #152: a saved instrument level does not reach the bounce. | Plugin format | AAX only (Native and DSP) | CLAP, VST3, Audio Units (macOS) | Users' libraries mostly ship VST3/AU too. AAX (#109) needs Avid's SDK licence and PACE signing: owner decision, likely never | | VST2, LV2, LADSPA | — | ❌ (#30) | Beyond Pro Tools; VST2 SDK is no longer licensable | | Scan, vendor menus, instrument picker | ✅ | ✅ | #23 |-| Audio, parameters, automation, latency (PDC) | ✅ | ✅ | |+| Audio, parameters, automation, latency (PDC) | ✅ | ✅ | `mix.plugin_params` lists built-in and hosted plugins' parameter ids and metadata. Hosted instruments too: `mix.insert_param` with `slot: "instrument"`, `plugin:instrument:<id>` lanes (#1065) | | Notes / MIDI to instruments | ✅ | ✅ | But no live MIDI input (G-02) | | State saved in sessions, presets | ✅ | ✅ | | | Create/destroy off the audio thread | ✅ | ✅ | |@@ -58,6 +58,8 @@ User report #152: a saved instrument level does not reach the bounce.  | Date | Change | Summary | |---|---|---|+| 2026-10-25 | trivial | `mix.plugin_params` lists parameter ids, metadata and stored/default values for built-in and hosted inserts and instruments (#1065) |+| 2026-10-25 | trivial | Hosted instruments' parameters: `mix.insert_param`/`mix.insert_params` take `slot: "instrument"`, `mix.instrument_param` sets hosted instruments, `plugin:instrument:<id>` automation, numeric parameter ids (#1065) | | 2026-10-25 | trivial | Auto Pan: constant-power balance of the left and right channels; stereo passes unchanged at the center | | 2026-10-24 | minor | AudioSuite speed ramps (`speed_ramp`): down to a stop and up from one, Fit To or Extend, fades on or off | | 2026-10-24 | minor | Auto Pan: LFO and envelope panning of stereo tracks; built-in count 27 processors + 3 instruments |