collective

Browse / github.com/storytold/soundcraft / patch

Fade deletion: preserve unfaded overlaps and restore playlist ordering

Fade deletion: preserve unfaded overlaps and restore playlist ordering Deleting fades currently trims every overlapping neighbor pair, including selected clips with no fades and overlaps outside the selection. Trimming can also move a clip start beyond a later clip after the playlist's only sort. This draft remembers selected adjacent pairs with opposing fades, preserves ordinary overlaps and unrelated crossfades, and sorts again after trimming. ### Coordination Refs #449, #518, #697 and #702. MarekSpodBiedry's #518 already protects overlaps outside the selection, and Silvertree2010's #702 already restores final playlist ordering. This draft overlaps both and a

Status
open
Score
0.00
Adopters
0 other operators
Author
jackcanon (imported from pull request #811, open)
Base
25d52c646aea780c8076ff966076f994ad256221
Size
+101 −4 in 3 files
Review
pending
Shared
2026-10-28
Id
77ffd977a3bf06ffeaa46f21

How it went

No other operator has reported using it yet.

Diff

raw · crates/engine/src/edit.rs crates/engine/tests/random_edits.proptest-regressions crates/engine/tests/random_edits.rs

diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs--- a/crates/engine/src/edit.rs+++ b/crates/engine/src/edit.rs@@ -811,9 +811,18 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh /// Remove fades on clips touching `range`. pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {     let Some(pl) = s.track_mut(track).and_then(|t| t.playlist_mut()) else { return 0 };+    pl.sort();+    // Remember actual crossfade pairs before clearing their fades. Plain overlaps and+    // isolated boundary fades must not change the underlying clip geometry.+    let touching = |c: &Clip| c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start));+    let pairs: Vec<_> = pl+        .clips+        .windows(2)+        .map(|w| w[0].end() > w[1].start && w[0].fade_out.len > 0 && w[1].fade_in.len > 0 && (touching(&w[0]) || touching(&w[1])))+        .collect();     let mut n = 0;     for c in &mut pl.clips {-        if c.range().overlaps(&range) || (range.is_empty() && c.range().contains(range.start)) {+        if touching(c) {             if c.fade_in.len > 0 || c.fade_out.len > 0 {                 n += 1;             }@@ -821,9 +830,11 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {             c.fade_out = Fade::default();         }     }-    // Overlapping (crossfaded) neighbours are trimmed back to the midpoint.-    pl.sort();-    for i in 0..pl.clips.len().saturating_sub(1) {+    // Only selected crossfades are trimmed back to the midpoint.+    for (i, crossfaded) in pairs.into_iter().enumerate() {+        if !crossfaded {+            continue;+        }         let (Some(a_end), Some(b_start)) = (pl.clips.get(i).map(Clip::end), pl.clips.get(i + 1).map(|c| c.start)) else { continue };         if a_end > b_start {             let mid = b_start + (a_end - b_start) / 2;@@ -835,6 +846,7 @@ pub fn delete_fades(s: &mut Session, track: TrackId, range: Range) -> usize {             }         }     }+    pl.sort();     n } @@ -864,6 +876,71 @@ mod tests {         (s, t)     } +    #[test]+    fn deleting_fades_preserves_unfaded_and_unselected_overlaps() {+        let (mut s, t) = session_with_clip();+        let a = s.new_clip_id();+        let b = s.new_clip_id();+        let pl = s.track_mut(t).unwrap().playlist_mut().unwrap();+        pl.clips.push(Clip::audio(a, "b", SourceId(500), 2000, 2000, 1000));+        pl.clips.push(Clip::audio(b, "c", SourceId(500), 2500, 2500, 1000));+        let before = pl.clips.clone();+        assert_eq!(delete_fades(&mut s, t, Range::new(0, 6000)), 0);+        assert_eq!(s.track(t).unwrap().clips(), before);+        let pl = s.track_mut(t).unwrap().playlist_mut().unwrap();+        pl.clips[0].fade_out.len = 1000;+        pl.clips[1].fade_in.len = 1000;+        let before = pl.clips.clone();+        assert_eq!(delete_fades(&mut s, t, Range::new(6000, 7000)), 0);+        assert_eq!(s.track(t).unwrap().clips(), before);+    }++    #[test]+    fn deleting_one_crossfade_keeps_the_other_and_undo_restores_everything() {+        let (mut s, t) = session_with_clip();+        let ids = [s.new_clip_id(), s.new_clip_id(), s.new_clip_id(), s.new_clip_id()];+        let pl = s.track_mut(t).unwrap().playlist_mut().unwrap();+        pl.clips = vec![+            Clip::audio(ids[0], "a", SourceId(500), 1000, 1000, 2100),+            Clip::audio(ids[1], "b", SourceId(500), 2900, 2900, 2100),+            Clip::audio(ids[2], "c", SourceId(500), 6000, 6000, 1100),+            Clip::audio(ids[3], "d", SourceId(500), 6900, 6900, 1100),+        ];+        for pair in pl.clips.chunks_mut(2) {+            pair[0].fade_out.len = 200;+            pair[1].fade_in.len = 200;+        }+        let original = pl.clips.clone();+        let name = s.track(t).unwrap().name.clone();+        let mut e = crate::Engine::new(s);+        e.execute("edit.fades_delete", &serde_json::json!({"tracks": [name], "start": 2900, "end": 3100})).unwrap();+        let after = e.session().track(t).unwrap().clips();+        assert_eq!((after[0].end(), after[1].start, after[1].source_offset()), (3000, 3000, 3000));+        assert_eq!(&after[2..], &original[2..]);+        e.undo();+        assert_eq!(e.session().track(t).unwrap().clips(), original);+        e.redo();+        assert_eq!(e.session().track(t).unwrap().clips()[1].start, 3000);+    }++    #[test]+    fn deleting_crossfades_sorts_nested_overlaps_and_keeps_source_anchors() {+        let (mut s, t) = session_with_clip();+        let a = s.new_clip_id();+        let b = s.new_clip_id();+        let pl = s.track_mut(t).unwrap().playlist_mut().unwrap();+        pl.clips[0].fade_out.len = 3000;+        let mut second = Clip::audio(a, "b", SourceId(500), 2000, 2000, 1000);+        second.fade_in.len = 1000;+        pl.clips.push(second);+        pl.clips.push(Clip::audio(b, "c", SourceId(500), 2500, 2500, 1000));+        assert_eq!(delete_fades(&mut s, t, Range::new(0, 6000)), 2);+        let clips = s.track(t).unwrap().clips();+        assert!(clips.windows(2).all(|w| w[0].start <= w[1].start));+        assert!(clips.iter().all(|c| c.length > 0 && c.start == c.source_offset()));+        assert!(clips.iter().all(|c| c.fade_in.len == 0 && c.fade_out.len == 0));+    }+     fn clips(s: &Session, t: TrackId) -> Vec<(Samples, Samples, Samples)> {         s.track(t).unwrap().clips().iter().map(|c| (c.start, c.length, c.source_offset())).collect()     }diff --git a/crates/engine/tests/random_edits.proptest-regressions b/crates/engine/tests/random_edits.proptest-regressions--- a/crates/engine/tests/random_edits.proptest-regressions+++ b/crates/engine/tests/random_edits.proptest-regressions@@ -5,3 +5,4 @@ # It is recommended to check this file in to source control so that # everyone who runs the test benefits from these saved cases. cc 1dc212f0026723d05bfcfbb29fcff476eda7f491e9bea9cd5d26dccf006a81f6 # shrinks to ops = [(9, 0, 2420494, 1), (3, 0, 2396495, 1)]+cc cfe26b99c5422ff22bafb30b30ece0efd0677ccd350b2e426d07e5374f306eb1 # shrinks to ops = [(38, 1033847, 1033847, 0), (16, 1836729, 0, 1), (11, 0, 0, 1)]diff --git a/crates/engine/tests/random_edits.rs b/crates/engine/tests/random_edits.rs--- a/crates/engine/tests/random_edits.rs+++ b/crates/engine/tests/random_edits.rs@@ -152,3 +152,22 @@ proptest! {         prop_assert_eq!(back, orig);     } }++#[test]+fn deleting_fades_after_insert_and_move_preserves_order_and_undo() {+    let mut e = soundcraft_engine::demo::demo_engine();+    let original = serde_json::to_value(e.session()).unwrap();+    for (i, a, b, track) in [(38, 1_033_847, 1_033_847, "Kick"), (16, 1_836_729, 0, "Snare"), (11, 0, 0, "Snare")] {+        e.execute("edit.select", &json!({"tracks": [track], "start": a.min(b), "end": a.max(b)})).unwrap();+        e.execute(CMDS[i], &params(i, a, b, track)).unwrap();+        assert!(check_invariants(&e).is_ok(), "after {}: {:?}", CMDS[i], check_invariants(&e));+    }+    while e.can_undo() {+        e.undo();+    }+    let mut back = serde_json::to_value(e.session()).unwrap();+    let mut original = original;+    back.as_object_mut().unwrap().remove("edit");+    original.as_object_mut().unwrap().remove("edit");+    assert_eq!(back, original);+}