diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index 3e5d75cc..5e8a0372 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -40,7 +40,7 @@ pub fn specs() -> Vec { } 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 { }), |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 { + 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 { 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 { [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 index 4929b468..8a0509b0 100644 --- a/crates/engine/src/cmd/track.rs +++ b/crates/engine/src/cmd/track.rs @@ -97,8 +97,8 @@ pub fn specs() -> Vec { 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 index 9f547597..14c66192 100644 --- 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 { 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 index 03d173dd..f64c64bf 100644 --- 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) { 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 index bef493e8..244f1708 100644 --- 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 index b72818dc..3c6d47aa 100644 --- 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();