fix(engine): keep live input states transient

This commit is contained in:
Sly LaMenace committed 2026-10-19 09:42:51 +00:00
1 parent 25d52c646a
commit 352d90a24a
6 files changed
+117 -11

No files matched your search

+51 -2
View File
@@ -40,7 +40,7 @@ pub fn specs() -> Vec<CommandSpec> {
}
Ok(json!({}))
}),
cmd!("mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
cmd!(noundo "mix.record_arm", "Record Enable", [], Some("Shift+R"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
e,
p,
"mix.record_arm",
@@ -49,7 +49,7 @@ pub fn specs() -> Vec<CommandSpec> {
}),
|tr| tr.mixer.record_arm
)),
cmd!("mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle(
cmd!(noundo "mix.input_monitor", "Input Monitoring", [], Some("Shift+I"), "{tracks?, value?: bool}", has_selection, |e, p| toggle_runtime(
e,
p,
"mix.input_monitor",
@@ -216,6 +216,22 @@ fn toggle(e: &mut Engine, p: &Value, cmd: &str, set: fn(&mut Session, TrackId, b
Ok(json!({"value": v}))
}
fn toggle_runtime(
e: &mut Engine,
p: &Value,
cmd: &str,
set: fn(&mut Session, TrackId, bool),
get: fn(&soundcraft_model::Track) -> bool,
) -> Result<Value> {
let tracks = tracks_required(e, cmd, p)?;
let s = e.runtime_session_mut();
let v = p.get("value").and_then(Value::as_bool).unwrap_or_else(|| !tracks.iter().all(|t| s.track(*t).is_some_and(get)));
for t in &tracks {
set(s, *t, v);
}
Ok(json!({"value": v}))
}
fn volume(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "mix.volume", p)?;
let abs = p.get("db").and_then(Value::as_f64);
@@ -947,6 +963,39 @@ mod tests {
e
}
#[test]
fn live_input_states_are_transient_and_survive_document_history() {
let mut e = Engine::default();
e.execute("track.new", &json!({})).unwrap();
let track = e.session().tracks[0].id;
e.mark_clean();
let history = e.undo_history();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();
e.execute("track.set_record_to_input_only", &json!({})).unwrap();
let mixer = &e.session().track(track).unwrap().mixer;
assert!(mixer.record_arm && mixer.input_monitor);
assert!(!e.is_dirty(), "live input state is not a document change");
assert_eq!(e.undo_history(), history, "live input state has no Undo entry");
// A later document edit captures a full Session snapshot. Undoing or redoing it must not
// resurrect the live state that happened to be present when that snapshot was taken.
e.execute("mix.volume", &json!({"tracks": [track.0], "db": -6.0})).unwrap();
e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": false})).unwrap();
e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": false})).unwrap();
assert!(e.is_dirty());
assert!(e.undo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(!e.is_dirty(), "undo returned to the saved document");
assert!(e.redo());
let mixer = &e.session().track(track).unwrap().mixer;
assert!(!mixer.record_arm && !mixer.input_monitor);
assert!(e.is_dirty());
}
fn volumes(e: &Engine) -> Vec<f32> {
[3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()
}
+2 -2
View File
@@ -97,8 +97,8 @@ pub fn specs() -> Vec<CommandSpec> {
cmd!("track.bounce", "Bounce...", ["Track"], Some("Cmd+Alt+Shift+B"), "{tracks?}", has_selection, commit),
cmd!("track.bypass_inserts", "All", ["Track", "Bypass Inserts"], None, "{tracks?, bypass?: bool}", has_selection, bypass_inserts),
cmd!("track.mute_sends", "All", ["Track", "Mute Sends"], None, "{tracks?, mute?: bool}", has_selection, mute_sends),
cmd!("track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
let s = e.session_mut();
cmd!(noundo "track.set_record_to_input_only", "Set Record Tracks to Input Only", ["Track"], Some("Alt+K"), "{}", always, |e, _| {
let s = e.runtime_session_mut();
let mut n = 0;
for t in &mut s.tracks {
if t.mixer.record_arm {
+9 -4
View File
@@ -283,15 +283,17 @@ fn load_preset(e: &mut Engine, p: &Value) -> Result<Value> {
let s = e.session_mut();
for t in &tracks {
if let Some(tr) = s.track_mut(*t) {
// Routing is session-specific: keep the track's own input/output, VCA and arm state.
let keep = (tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.pan.clone());
// Routing and live input state are track-specific: a preset must not replace them.
let keep =
(tr.mixer.input.clone(), tr.mixer.output.clone(), tr.mixer.vca, tr.mixer.record_arm, tr.mixer.input_monitor, tr.mixer.pan.clone());
tr.mixer = mixer.clone();
tr.mixer.input = keep.0;
tr.mixer.output = keep.1;
tr.mixer.vca = keep.2;
tr.mixer.record_arm = keep.3;
if tr.mixer.pan.len() != keep.4.len() {
tr.mixer.pan = keep.4;
tr.mixer.input_monitor = keep.4;
if tr.mixer.pan.len() != keep.5.len() {
tr.mixer.pan = keep.5;
}
if tr.kind == TrackKind::Instrument && instrument.is_some() {
tr.instrument = instrument.clone();
@@ -612,10 +614,13 @@ mod tests {
e.execute("edit.select", &json!({"tracks": ["Bass"]})).unwrap();
e.execute("track.save_preset", &json!({"path": path})).unwrap();
e.execute("edit.select", &json!({"tracks": ["Hats"]})).unwrap();
e.execute("mix.record_arm", &json!({"value": true})).unwrap();
e.execute("mix.input_monitor", &json!({"value": true})).unwrap();
e.execute("track.load_preset", &json!({"path": path})).unwrap();
let hats = e.session().track_by_name("Hats").unwrap();
assert_eq!(hats.mixer.inserts[0].as_ref().map(|i| i.plugin.as_str()), Some("compressor"));
assert_eq!(hats.mixer.volume_db, -5.0);
assert!(hats.mixer.record_arm && hats.mixer.input_monitor, "loading a preset keeps live input state");
let _ = std::fs::remove_dir_all(&dir);
}
+27 -1
View File
@@ -187,6 +187,13 @@ impl Engine {
Arc::make_mut(&mut self.doc)
}
/// Mutable access for live state that redraws and reaches playback but is not part of the
/// saved document. Callers must also keep the fields out of serialization and Undo/Redo.
pub(crate) fn runtime_session_mut(&mut self) -> &mut Session {
self.revision = self.revision.wrapping_add(1);
Arc::make_mut(&mut self.doc)
}
/// Like `session_mut`, for `edit.zoom` only and without bumping `revision`.
pub fn zoom_mut(&mut self) -> &mut ZoomState {
self.dirty = true;
@@ -246,6 +253,7 @@ impl Engine {
let Some(h) = self.undo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.keep_runtime_track_states(&cur);
self.redo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
@@ -256,6 +264,7 @@ impl Engine {
let Some(h) = self.redo.pop() else { return false };
let cur = std::mem::replace(&mut self.doc, h.doc);
self.keep_edit_mode(&cur);
self.keep_runtime_track_states(&cur);
self.undo.push(HistoryEntry { label: h.label, doc: cur });
self.revision = self.revision.wrapping_add(1);
self.dirty = true;
@@ -276,6 +285,17 @@ impl Engine {
}
}
/// Undo and Redo must not restore live input states captured in document snapshots. A track
/// absent from the current document comes back safe rather than inheriting a historical arm.
fn keep_runtime_track_states(&mut self, from: &Session) {
for track in &mut Arc::make_mut(&mut self.doc).tracks {
let (record_arm, input_monitor) =
from.track(track.id).map_or((false, false), |current| (current.mixer.record_arm, current.mixer.input_monitor));
track.mixer.record_arm = record_arm;
track.mixer.input_monitor = input_monitor;
}
}
/// Record an undo point manually (for interactive gestures committed by the UI).
pub fn push_undo(&mut self, label: &str, before: Arc<Session>) {
if Arc::ptr_eq(&before, &self.doc) {
@@ -424,7 +444,7 @@ impl Engine {
}
}
/// Equal apart from what changes without editing: the selection, the playhead and the zoom.
/// Equal apart from what changes without editing: the selection, playhead, zoom and live input states.
fn same_document(saved: &Session, doc: &Session) -> bool {
let mut doc = doc.clone();
let (from, to) = (&saved.edit, &mut doc.edit);
@@ -434,6 +454,12 @@ fn same_document(saved: &Session, doc: &Session) -> bool {
to.selected_clips.clone_from(&from.selected_clips);
to.playhead = from.playhead;
to.zoom = from.zoom;
for track in &mut doc.tracks {
if let Some(saved_track) = saved.track(track.id) {
track.mixer.record_arm = saved_track.mixer.record_arm;
track.mixer.input_monitor = saved_track.mixer.input_monitor;
}
}
*saved == doc
}
+4 -2
View File
@@ -502,9 +502,11 @@ pub struct Mixer {
pub solo: bool,
#[serde(default)]
pub solo_safe: bool,
#[serde(default)]
/// Live record-enable state. Runtime-only: opening a session must never arm an input.
#[serde(skip)]
pub record_arm: bool,
#[serde(default)]
/// Live input-monitor state. Runtime-only: opening a session must never enable monitoring.
#[serde(skip)]
pub input_monitor: bool,
#[serde(default)]
pub phase_invert: bool,
+24
View File
@@ -681,6 +681,30 @@ mod tests {
assert_eq!(back, s);
}
#[test]
fn live_input_states_are_never_saved_or_restored() {
let mut s = Session::default();
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
let track = s.track_mut(t).unwrap();
track.mixer.mute = true;
track.mixer.record_arm = true;
track.mixer.input_monitor = true;
let mut value = serde_json::to_value(&s).unwrap();
let mixer = value["tracks"][0]["mixer"].as_object_mut().unwrap();
assert!(!mixer.contains_key("record_arm"));
assert!(!mixer.contains_key("input_monitor"));
// Files written by older versions may still contain these keys; ignore them on input.
mixer.insert("record_arm".into(), serde_json::Value::Bool(true));
mixer.insert("input_monitor".into(), serde_json::Value::Bool(true));
let loaded = Session::from_json(&value.to_string()).unwrap();
let track = loaded.track(t).unwrap();
assert!(track.mixer.mute, "persistent mixer state still loads");
assert!(!track.mixer.record_arm);
assert!(!track.mixer.input_monitor);
}
#[test]
fn video_clip_round_trips_and_old_sessions_load() {
let mut s = Session::default();