Files
soundcraft/crates/engine/src/cmd/plugin_state.rs
T
grovebit 992f60c79c Trackpad-friendly Edit window: smooth panning, pinch and Cmd+scroll zoom
- Pinch and Cmd+scroll zoom the timeline around the pointer. egui 0.36 turns
  both into zoom_delta, so the old Cmd+scroll branch never ran; Shift+wheel
  pans sideways again for the same reason.
- Two-finger panning keeps the sub-sample remainder, so slow motion still
  moves at the closest zoom; the scrollbar drag pans the same way (its range
  now takes in the view, so the thumb stays in the bar), and the rulers pan
  too. Panning stays open-ended to the right, as before.
- A swipe keeps to the axis it starts along, so panning doesn't drift between
  tracks and scrolling doesn't drift in time. The larger share of its first
  6 points decides until egui reports the scroll over: the fingers lift, or
  150 ms pass without wheel events.
- The MIDI editor scrolls by the point instead of rounding each frame's delta
  to whole rows (slow swipes did nothing), and its range fits the panel
  (pitch 0 could not be reached; a taller panel showed blank rows).
- Zoom and scroll write through `Engine::zoom_mut` (`view` commands): no
  playback resync (a session copy sent to the audio thread every frame), no
  copy of the whole session per frame, no journal entry per frame, and no
  end to an undo gesture (scrolling during an automation pass made each
  write its own undo step). New `view.zoom_by {factor, x?}`;
  `view.scroll` saturates; Fill Window and Zoom to Selection stay within the
  zoom range (zooming out after them on a long session zoomed in).
- Control channel: `ui.scroll` (trackpad swipe or wheel notches) and
  `ui.zoom` (pinch); `session.inspect` reports zoom.
2026-10-11 15:21:46 +01:00

120 lines
5.1 KiB
Rust

//! Third-party plugin state in the session (`mix.insert_state`): the UI reads each live plugin's
//! state from the audio engine right before saving and stores it on its insert (base64), from
//! where the mixer restores it into new instances.
use super::*;
use serde_json::json;
use soundcraft_model::{INSERT_SLOTS, b64};
pub fn specs() -> Vec<CommandSpec> {
vec![CommandSpec {
id: "mix.insert_state",
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).",
enabled: has_tracks,
run: insert_state,
journal: false,
undoable: false,
view: false,
}]
}
const CMD: &str = "mix.insert_state";
/// `None` = the instrument slot.
fn slot_param(p: &Value) -> Result<Option<usize>> {
let v = p.get("slot").ok_or_else(|| bad(CMD, "`slot` required"))?;
if let Some(n) = v.as_u64() {
return usize::try_from(n).ok().filter(|&n| n < INSERT_SLOTS).map(Some).ok_or_else(|| bad(CMD, "slot out of range"));
}
let s = v.as_str().map(|s| s.trim().to_ascii_lowercase()).ok_or_else(|| bad(CMD, "`slot` must be a number or a letter"))?;
if s == "instrument" {
return Ok(None);
}
match s.as_bytes() {
[c @ b'a'..=b'z'] if usize::from(c - b'a') < INSERT_SLOTS => Ok(Some(usize::from(c - b'a'))),
_ => s.parse::<usize>().ok().filter(|&n| n < INSERT_SLOTS).map(Some).ok_or_else(|| bad(CMD, "slot out of range")),
}
}
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)?;
let state = match p.get("state") {
None | Some(Value::Null) => None,
Some(Value::String(s)) => Some(b64::decode(s).ok_or_else(|| bad(CMD, "`state` is not valid base64 (or is too large)"))?),
Some(_) => return Err(bad(CMD, "`state` must be a base64 string or null")),
};
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(),
}
.ok_or_else(|| bad(CMD, "empty slot"))?;
let n = state.as_ref().map_or(0, Vec::len);
match state {
Some(bytes) => ins.set_state_bytes(&bytes),
None => ins.state = None,
}
Ok(json!({"bytes": n}))
}
#[cfg(test)]
mod tests {
use crate::Engine;
use serde_json::json;
use soundcraft_model::b64;
#[test]
fn stores_and_clears_state_without_undo() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
e.execute("mix.insert", &json!({"track": t, "plugin": "gain", "slot": 2})).unwrap();
let undo_before = e.can_undo();
let blob = b64::encode(b"\x00\x01plugin state\xff");
let r = e.execute("mix.insert_state", &json!({"track": t, "slot": "c", "state": blob})).unwrap();
assert_eq!(r["bytes"], 15);
let ins = e.session().tracks[0].mixer.inserts[2].clone().unwrap();
assert_eq!(ins.state_bytes().unwrap(), b"\x00\x01plugin state\xff");
assert_eq!(e.can_undo(), undo_before, "not undoable");
assert!(e.journal.iter().all(|(id, _)| id != "mix.insert_state"), "not journaled");
// Survives save/load.
let dir = std::env::temp_dir().join(format!("soundcraft-insert-state-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("s.scraft");
e.execute("session.save", &json!({"path": path.to_string_lossy()})).unwrap();
let mut f = Engine::default();
f.execute("session.open", &json!({"path": path.to_string_lossy()})).unwrap();
assert_eq!(f.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, ins.state);
let _ = std::fs::remove_dir_all(&dir);
e.execute("mix.insert_state", &json!({"track": t, "slot": 2, "state": null})).unwrap();
assert_eq!(e.session().tracks[0].mixer.inserts[2].as_ref().unwrap().state, None);
}
#[test]
fn hostile_params_are_errors() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let t = e.session().tracks[0].id.0;
e.execute("mix.insert", &json!({"track": t, "plugin": "gain", "slot": 0})).unwrap();
for p in [
json!({"track": t, "slot": 0, "state": "!!!"}),
json!({"track": t, "slot": 0, "state": 5}),
json!({"track": t, "slot": 1, "state": "AAAA"}),
json!({"track": t, "slot": 99, "state": "AAAA"}),
json!({"track": t, "slot": "instrument", "state": "AAAA"}),
json!({"track": t, "slot": -1}),
json!({"track": t}),
json!({"track": 999, "slot": 0}),
json!({"slot": 0}),
] {
assert!(e.execute("mix.insert_state", &p).is_err(), "{p}");
}
}
}