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
@@ -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() }@@ -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 {@@ -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); } @@ -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 } @@ -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,@@ -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();