Write send-level automation from the send faders (#1146)

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).
This commit is contained in:
Ayoze Torres committed 2026-10-27 22:14:20 +00:00
1 parent 40f593a360
commit 7c029c40ce
6 files changed
+112 -8

No files matched your search

+4 -3
View File
@@ -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();
}
}
+41 -4
View File
@@ -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();
+1 -1
View File
@@ -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));
+3
View File
@@ -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<TrackId>,
/// 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`).
+2
View File
@@ -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 <track>" 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}),
+61
View File
@@ -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());