Browse / github.com/storytold/soundcraft / patch
Hosted instruments: set parameters and automate them by command (#1065, part 1)
Hosted instruments: set parameters and automate them by command (#1065, part 1) ## What Part 1 of #1065: a hosted instrument (CLAP, VST3, Audio Unit) can now be controlled by command. Until now, the only option was an opaque state blob (`mix.insert_state`). - `mix.insert_param` and `mix.insert_params` accept `slot: "instrument"`. On a track without an instrument this fails with "the track has no instrument". The other insert commands (`insert_remove`, `insert_bypass`, …) still refuse it. - `mix.instrument_param` sets hosted instruments too. Before, it answered "`…` is not a built-in instrument". It is now the same as `mix.insert_param` with `slot: "instrument"`. - A par
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- sanderdewijs (imported from pull request #1066, open)
- Base
7dc56a4dbeebf57eacece09536726c57844fc59d- Size
- +1066 −97 in 12 files
- Review
- pending
- Shared
- 2026-10-28
- Id
35bf8732891356706374d63f
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_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/plugin-parity.md
@@ -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 = false@@ -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(¶m).ok_or_else(|| bad(id, format!("`{}` has no parameter `{param}`", info.id)))?; let v = pi.clamp(value); ins.params.insert(param.clone(), v);@@ -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)]+enum PluginSlot {+ Insert(usize),+ Instrument,+}++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, ¶m), 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(¶m)).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(¶m)).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();+ } }@@ -232,6 +232,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) };@@ -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();@@ -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);@@ -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)); }@@ -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};@@ -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) }@@ -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() } } @@ -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", ¶ms, &key),- None => self.engine.execute("mix.insert_state", ¶ms),- };- 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", ¶ms) {+ 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", ¶ms) {+ 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(¶m));+ if held {+ self.editor_instrument_params.retain(|(pt, pp, _)| (*pt, pp) != (t, ¶m));+ 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); } }@@ -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 (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) | ✅ | ✅ | 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,7 @@ User report #152: a saved instrument level does not reach the bounce. | Date | Change | Summary | |---|---|---|+| 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 |