From 7c029c40cebdd7054e4e4d49bf61ace4c040c950 Mon Sep 17 00:00:00 2001 From: Ayoze Torres <53948812+ayozetr@users.noreply.github.com> Date: Tue, 27 Oct 2026 22:14:20 +0000 Subject: [PATCH] Write send-level automation from the send faders (#1146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving a send's level with automation enabled wrote nothing: the live pass in write_automation only recorded each track's main fader (`volume`). Send levels have their own lanes (`send_a_level` …), which playback reads, but nothing wrote them while playing. The send's level control in the strip slot and its floating send window now start a SendFader gesture, released like the main fader's. The pass writes the send's level with the track's mode, the same rules as the volume: Write throughout, Touch while the send is dragged, Latch and Touch/Latch from the first drag until stop (touched sends are kept per track and slot). --- crates/ui-egui/src/app.rs | 7 +-- crates/ui-egui/src/app/playback.rs | 45 +++++++++++++++++-- crates/ui-egui/src/edit_window/drag.rs | 2 +- crates/ui-egui/src/lib.rs | 3 ++ crates/ui-egui/src/mix_window/sends.rs | 2 + crates/ui-egui/src/tests.rs | 61 ++++++++++++++++++++++++++ 6 files changed, 112 insertions(+), 8 deletions(-) diff --git a/crates/ui-egui/src/app.rs b/crates/ui-egui/src/app.rs index c3289263..613370dc 100644 --- a/crates/ui-egui/src/app.rs +++ b/crates/ui-egui/src/app.rs @@ -94,6 +94,7 @@ impl SoundApp { quit_requested: false, arrange_request: false, touched: std::collections::HashSet::new(), + touched_sends: std::collections::HashSet::new(), last_write_at: i64::MIN, editor_states: Vec::new(), editor_poll_due: true, @@ -148,7 +149,7 @@ impl SoundApp { if hidden { self.pointer_down = false; self.wants_keyboard = false; - if matches!(self.gesture, Some(Gesture::Fader { .. })) { + if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { self.gesture = None; } } @@ -169,11 +170,11 @@ impl SoundApp { updates_ui::tick(self, ctx); } if !self.pointer_down { - if matches!(self.gesture, Some(Gesture::Fader { .. })) { + if matches!(self.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { self.gesture = None; } // Keep a running automation pass merged until playback stops. - if !self.wants_keyboard && self.touched.is_empty() { + if !self.wants_keyboard && self.touched.is_empty() && self.touched_sends.is_empty() { self.engine.end_merge(); } } diff --git a/crates/ui-egui/src/app/playback.rs b/crates/ui-egui/src/app/playback.rs index fd17adc4..eb508f0a 100644 --- a/crates/ui-egui/src/app/playback.rs +++ b/crates/ui-egui/src/app/playback.rs @@ -204,7 +204,8 @@ impl SoundApp { } /// While playing, record volume automation from fader positions for tracks in Write mode, and - /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Each point replaces the + /// for Touch/Latch tracks whose fader is (or, for Latch, was) being moved. Send levels follow the + /// same rules, per send, from its slot or its floating send window. Each point replaces the /// automation since the previous one, and Stop writes a last point. A pass and whatever is /// dragged during it, its fader included, are one undo step. pub(crate) fn write_automation(&mut self) { @@ -214,7 +215,7 @@ impl SoundApp { let may_write = playing && self.engine.session().tracks.iter().any(|t| matches!(t.mixer.automation_mode, M::Write | M::Touch | M::Latch | M::TouchLatch)); self.engine.hold_merge(may_write.then_some(PASS)); - if !playing && self.touched.is_empty() { + if !playing && self.touched.is_empty() && self.touched_sends.is_empty() { self.last_write_at = i64::MIN; return; } @@ -230,6 +231,10 @@ impl SoundApp { Some(Gesture::Fader { track, .. }) => Some(*track), _ => None, }; + let send_fader = match &self.gesture { + Some(Gesture::SendFader { track, slot }) => Some((*track, *slot)), + _ => None, + }; let writes: Vec<(TrackId, f32)> = self .engine .session() @@ -247,9 +252,39 @@ impl SoundApp { write.then_some((t.id, t.mixer.volume_db)) }) .collect(); - for (t, v) in writes { + let send_writes: Vec<(TrackId, usize, f32)> = self + .engine + .session() + .tracks + .iter() + .flat_map(|t| { + let m = t.mixer.automation_mode; + t.mixer.sends.iter().enumerate().filter_map(move |(slot, s)| s.as_ref().map(|s| (t.id, m, slot, s.level_db))) + }) + .filter(|&(t, m, slot, _)| { + let touched_now = dragging && send_fader == Some((t, slot)); + match m { + M::Write => true, + M::Touch => touched_now, + M::Latch | M::TouchLatch => touched_now || self.touched_sends.contains(&(t, slot)), + _ => false, + } + }) + .map(|(t, _, slot, db)| (t, slot, db)) + .collect(); + let points: Vec<(TrackId, String, f32)> = writes + .iter() + .map(|&(t, v)| (t, "volume".to_string(), v)) + .chain(send_writes.iter().map(|&(t, slot, v)| (t, format!("send_{}_level", (b'a' + slot as u8) as char), v))) + .collect(); + for &(t, _) in &writes { self.touched.insert(t); - let mut point = serde_json::json!({"track": t.0, "param": "volume", "at": pos, "value": v}); + } + for &(t, slot, _) in &send_writes { + self.touched_sends.insert((t, slot)); + } + for (t, param, v) in points { + let mut point = serde_json::json!({"track": t.0, "param": param, "at": pos, "value": v}); if from != i64::MIN { point["from"] = from.into(); } @@ -257,6 +292,7 @@ impl SoundApp { } if !playing { self.touched.clear(); + self.touched_sends.clear(); self.engine.end_merge(); } } @@ -296,6 +332,7 @@ impl SoundApp { self.punch = None; self.gesture = None; self.touched.clear(); + self.touched_sends.clear(); self.last_write_at = i64::MIN; self.edit_layout.follow_hold = false; self.meters.clear(); diff --git a/crates/ui-egui/src/edit_window/drag.rs b/crates/ui-egui/src/edit_window/drag.rs index 09c6b326..d2d8eae3 100644 --- a/crates/ui-egui/src/edit_window/drag.rs +++ b/crates/ui-egui/src/edit_window/drag.rs @@ -32,7 +32,7 @@ pub(super) fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32, /// Runs before anything is painted, against the last frame's layout. pub(super) fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { - if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { + if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. } | Gesture::SendFader { .. })) { return; } let (s, tl) = (app.engine.session_arc(), rect(app.edit_layout.timeline)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 368c10bf..9c8c1be2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -235,6 +235,7 @@ pub enum Gesture { FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 }, Scrub { last: Samples }, Fader { track: TrackId, start_db: f32 }, + SendFader { track: TrackId, slot: usize }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, Breakpoint { track: TrackId, param: soundcraft_model::AutoParam, at: Samples }, @@ -382,6 +383,8 @@ pub struct SoundApp { pub arrange_request: bool, /// Tracks whose automation was touched during this pass (Touch/Latch writing). touched: std::collections::HashSet, + /// Sends whose level was touched during this pass (Touch/Latch writing), as (track, slot). + touched_sends: std::collections::HashSet<(TrackId, usize)>, /// The pass's last point; `i64::MIN` after a stop or a jump, so the next point erases nothing it skipped. last_write_at: Samples, /// Plugins changed in their editors whose state the session hasn't taken yet (see `editor_states_due`). diff --git a/crates/ui-egui/src/mix_window/sends.rs b/crates/ui-egui/src/mix_window/sends.rs index b1486087..e0ab578b 100644 --- a/crates/ui-egui/src/mix_window/sends.rs +++ b/crates/ui-egui/src/mix_window/sends.rs @@ -43,6 +43,7 @@ pub(crate) fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: us // applying it to the already-updated value made trackpad adjustment accelerate. let (dy, fine) = ui.input(|i| (i.pointer.delta().y, i.modifiers.shift)); let db = dragged_send_db(s.level_db, dy, fine); + app.gesture = Some(crate::Gesture::SendFader { track: id, slot }); let _ = app.engine.execute_merged("mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), &format!("send:{}:{slot}", id.0)); } @@ -117,6 +118,7 @@ fn send_window_contents(app: &mut SoundApp, ctx: &egui::Context) { // Distinct from the strip's "Send A level for " icon and from the slot's "Send level", so a // label query while the window is open still picks one node. if let Some(db) = fader(ui, fr, send.level_db, ui.scope_id().with("send_fader"), "Send level fader") { + app.gesture = Some(crate::Gesture::SendFader { track: id, slot }); let _ = app.engine.execute_merged( "mix.send_level", &json!({"track": id.0, "slot": slot, "db": db}), diff --git a/crates/ui-egui/src/tests.rs b/crates/ui-egui/src/tests.rs index bbbba444..6ebfb68f 100644 --- a/crates/ui-egui/src/tests.rs +++ b/crates/ui-egui/src/tests.rs @@ -244,6 +244,67 @@ fn a_pass_and_the_fader_ride_driving_it_are_one_undo_step() { assert_eq!(app.engine.undo_history().len(), steps + 1); } +/// #1146: a send's level writes its own automation lane, by the track's mode like its fader: Touch +/// while the send is dragged, Latch from the first drag until stop, Write throughout. The track's +/// volume lane is left alone. +#[test] +fn a_dragged_send_level_writes_send_automation() { + use soundcraft_model::AutoParam; + let points = |app: &SoundApp, param: &AutoParam| app.engine.session().track_by_name("Lead").unwrap().lane(param).map_or(0, |l| l.points.len()); + let level = |app: &SoundApp| app.engine.session().track_by_name("Lead").unwrap().lane(&AutoParam::SendLevel(0)).map(|l| l.points.clone()); + let pass = |app: &mut SoundApp| { + app.handle_transport(0.1); + app.write_automation(); + }; + for mode in ["touch", "latch", "write"] { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let lead = app.engine.session().track_by_name("Lead").unwrap().id; + assert!(app.engine.session().track(lead).unwrap().mixer.sends[0].is_some(), "the demo's Lead sends to the reverb"); + let volume = points(&app, &AutoParam::Volume); + app.run("mix.automation_mode", json!({"tracks": ["Lead"], "mode": mode})).unwrap(); + app.run("transport.play", json!({})).unwrap(); + for _ in 0..3 { + pass(&mut app); + } + let before_drag = points(&app, &AutoParam::SendLevel(0)); + if mode == "write" { + assert!(before_drag > 0, "Write writes the send without a drag"); + } else { + assert_eq!(before_drag, 0, "{mode}: nothing written before the send is touched"); + } + // What the send's slot and its floating window do while the level is dragged. + app.pointer_down = true; + app.gesture = Some(super::Gesture::SendFader { track: lead, slot: 0 }); + for frame in 0..4 { + pass(&mut app); + let db = -12.0 + frame as f32; + app.engine.execute_merged("mix.send_level", &json!({"track": lead.0, "slot": 0, "db": db}), &format!("send:{}:0", lead.0)).unwrap(); + } + let dragged = points(&app, &AutoParam::SendLevel(0)); + assert!(dragged >= before_drag + 3, "{mode}: the drag is written ({before_drag} -> {dragged})"); + assert!(level(&app).unwrap().iter().any(|p| (p.value - (-10.0)).abs() < 1e-6), "{mode}: the points follow the send level"); + // Released. + app.pointer_down = false; + app.gesture = None; + for _ in 0..3 { + pass(&mut app); + } + let released = points(&app, &AutoParam::SendLevel(0)); + if mode == "touch" { + assert_eq!(released, dragged, "Touch stops writing when the send is let go"); + } else { + assert!(released > dragged, "{mode}: keeps writing after the release"); + } + app.run("transport.stop", json!({})).unwrap(); + app.handle_transport(0.0); + app.write_automation(); + if mode != "write" { + assert_eq!(points(&app, &AutoParam::Volume), volume, "{mode}: the track's volume lane is left alone"); + } + assert!(app.touched_sends.is_empty(), "{mode}: stop ends the pass"); + } +} + #[test] fn plugin_states_read_during_a_drag_keep_it_one_undo_step() { let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default());