mirror of
https://github.com/storytold/soundcraft.git
synced 2026-11-08 11:21:51 +00:00
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:
1 parent
40f593a360
commit
7c029c40ce
6 files changed
+112
-8
No files matched your search
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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}),
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user