collective

Browse / github.com/storytold/soundcraft / patch

fix(engine): keep live input states transient

[Bug] Record-arm and input-monitoring states persist across sessions ## Description SoundCraft currently stores each track’s `record_arm` and `input_monitor` states in the session document. When a session is saved while tracks are record-enabled or input-monitored, these states are serialized. Reopening the session restores them, and the UI may then open the audio input device automatically. This can activate a live input path immediately after opening a session, without an explicit action from the user. Depending on the routing and monitoring configuration, this may cause feedback, loud transients, or other unexpected output. Record enable and input monito

Status
open
Score
0.00
Adopters
0 other operators
Author
SlyLaM (imported from pull request #782, open)
Base
25d52c646aea780c8076ff966076f994ad256221
Size
+130 −11 in 6 files
Review
pending
Shared
2026-10-28
Id
32089a1789b1fb82600f2ef5

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/cmd/mix.rs crates/engine/src/cmd/track.rs crates/engine/src/cmd/track_more.rs crates/engine/src/lib.rs crates/model/src/mixer.rs crates/model/src/session.rs

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@@ -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,52 @@ 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());++        // Live states that are enabled after another document edit survive both directions too.+        e.execute("mix.volume", &json!({"tracks": [track.0], "db": -12.0})).unwrap();+        e.execute("mix.record_arm", &json!({"tracks": [track.0], "value": true})).unwrap();+        e.execute("mix.input_monitor", &json!({"tracks": [track.0], "value": true})).unwrap();++        assert!(e.undo());+        let mixer = &e.session().track(track).unwrap().mixer;+        assert!(mixer.record_arm && mixer.input_monitor);++        assert!(e.redo());+        let mixer = &e.session().track(track).unwrap().mixer;+        assert!(mixer.record_arm && mixer.input_monitor);+    }+     fn volumes(e: &Engine) -> Vec<f32> {         [3, 6].iter().map(|id| e.session().track(TrackId(*id)).unwrap().mixer.volume_db).collect()     }diff --git a/crates/engine/src/cmd/track.rs b/crates/engine/src/cmd/track.rs--- a/crates/engine/src/cmd/track.rs+++ b/crates/engine/src/cmd/track.rs@@ -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 {diff --git a/crates/engine/src/cmd/track_more.rs b/crates/engine/src/cmd/track_more.rs--- a/crates/engine/src/cmd/track_more.rs+++ b/crates/engine/src/cmd/track_more.rs@@ -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);     } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs--- a/crates/engine/src/lib.rs+++ b/crates/engine/src/lib.rs@@ -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 } diff --git a/crates/model/src/mixer.rs b/crates/model/src/mixer.rs--- a/crates/model/src/mixer.rs+++ b/crates/model/src/mixer.rs@@ -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,diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs--- a/crates/model/src/session.rs+++ b/crates/model/src/session.rs@@ -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();