From 4ac47b3a7fc6765eb828f3eb12d30427be986194 Mon Sep 17 00:00:00 2001 From: reoniazx <76652649+reoniazx@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:08:51 +0800 Subject: [PATCH] Add draggable midpoint curvature for clip fades --- crates/engine/src/cmd/edit.rs | 56 ++++++- crates/engine/src/demo.rs | 2 +- crates/engine/src/edit.rs | 8 +- crates/mix/src/lib.rs | 30 +++- crates/model/src/clip.rs | 79 +++++++++- crates/model/src/session.rs | 2 + crates/ui-egui/src/edit_window.rs | 245 ++++++++++++++++++++++++++++-- crates/ui-egui/src/lib.rs | 1 + docs/file-format-parity.md | 5 +- docs/ui-parity.md | 4 +- 10 files changed, 400 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index b7532ca7..3dbde465 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -118,7 +118,7 @@ pub fn specs() -> Vec { "Create...", ["Edit", "Fades"], Some("Cmd+F"), - "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?}", + "{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?} or {clip: id, side: in|out, length: samples, shape?, curvature?: -1..1}", has_range, fades_create ), @@ -675,6 +675,13 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result { .map_or(Some(FadeShape::EqualPower), FadeShape::from_id) .ok_or_else(|| bad("edit.fades_create", "unknown fade shape"))?; if p.get("clip").is_some() || p.get("side").is_some() { + let curvature = match p.get("curvature") { + None => 0.0, + Some(value) => value + .as_f64() + .filter(|v| v.is_finite() && (-1.0..=1.0).contains(v)) + .ok_or_else(|| bad("edit.fades_create", "curvature must be a finite number from -1 to 1"))? as f32, + }; if ["track", "tracks", "clips", "start", "end"].iter().any(|key| p.get(key).is_some()) { return Err(bad("edit.fades_create", "clip fades cannot also specify tracks or an edit range")); } @@ -697,7 +704,7 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result { if p.get("shape").is_some_and(|v| !v.is_string()) { return Err(bad("edit.fades_create", "shape must be a fade shape name")); } - let fade = Fade { len: length.min(available), shape }; + let fade = Fade { len: length.min(available), shape, curvature }; let clip = e.session_mut().find_clip_mut(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?; if fade_in { clip.fade_in = fade; @@ -706,6 +713,9 @@ fn fades_create(e: &mut Engine, p: &Value) -> Result { } return Ok(json!({"fades": 1})); } + if p.get("curvature").is_some() { + return Err(bad("edit.fades_create", "curvature requires a clip and side")); + } let tracks = tracks_required(e, "edit.fades_create", p)?; let r = range_param(e, "edit.fades_create", p)?; let s = e.session_mut(); @@ -802,6 +812,40 @@ fn consolidate(e: &mut Engine, p: &Value) -> Result { mod tests { use super::*; + #[test] + fn targeted_fade_curvature_saves_reopens_and_undoes_without_changing_lengths() { + for side in ["in", "out"] { + let mut e = crate::demo::demo_engine(); + let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone(); + e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve"})).unwrap(); + let before = e.session().clone(); + let undo_len = e.undo_history().len(); + e.execute( + "edit.fades_create", + &json!({"clip": clip.id.0, "side": side, "length": clip.length / 2, "shape": "s-curve", "curvature": -0.65}), + ) + .unwrap(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + if side == "in" { + c.fade_in.curvature = -0.65; + } else { + c.fade_out.curvature = -0.65; + } + assert_eq!(e.session(), &expected); + let reopened = Session::from_json(&e.session().to_json().unwrap()).unwrap(); + let reopened = reopened.find_clip(clip.id).unwrap().1; + let stored = expected.find_clip(clip.id).unwrap().1; + assert_eq!(reopened.fade_in, stored.fade_in); + assert_eq!(reopened.fade_out, stored.fade_out); + assert_eq!(e.undo_history().len(), undo_len + 1); + assert!(e.undo()); + assert_eq!(e.session(), &before); + assert!(e.redo()); + assert_eq!(e.session(), &expected); + } + } + #[test] fn targeted_fades_fill_only_the_requested_side_and_undo_once() { for side in ["in", "out"] { @@ -816,7 +860,7 @@ mod tests { ); let mut expected = before.clone(); let c = expected.find_clip_mut(clip.id).unwrap(); - let fade = Fade { len: clip.length, shape: FadeShape::SCurve }; + let fade = Fade { len: clip.length, shape: FadeShape::SCurve, curvature: 0.0 }; if side == "in" { c.fade_in = fade; } else { @@ -852,6 +896,12 @@ mod tests { json!({"clip": id, "side": "in", "length": 10, "start": 0}), json!({"clip": id, "side": "in", "length": 10, "end": 100}), json!({"clip": id, "side": "in", "length": 10, "tracks": []}), + json!({"clip": id, "side": "in", "length": 10, "curvature": 2}), + json!({"clip": id, "side": "in", "length": 10, "curvature": -2}), + json!({"clip": id, "side": "in", "length": 10, "curvature": 1e100}), + json!({"clip": id, "side": "in", "length": 10, "curvature": null}), + json!({"clip": id, "side": "in", "length": 10, "curvature": "0.5"}), + json!({"start": 0, "end": 100, "curvature": 0.5}), ] { let before = e.session().clone(); let history = e.undo_history(); diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 1e7daa00..bdefafac 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -228,7 +228,7 @@ pub fn demo_session() -> Session { let len = total as i64 - start; let cid = s.new_clip_id(); let mut clip = Clip::audio(cid, name, src, start, start, len - (srf * 1.5) as i64); - clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve }; + clip.fade_out = Fade { len: (srf * 0.5) as i64, shape: FadeShape::SCurve, curvature: 0.0 }; crate::edit::place_clip(s, t, clip); t }; diff --git a/crates/engine/src/edit.rs b/crates/engine/src/edit.rs index 05cc0e69..b8bce21a 100644 --- a/crates/engine/src/edit.rs +++ b/crates/engine/src/edit.rs @@ -348,12 +348,12 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh let ext_b = before.min(b_handle).max(0); if let Some(ca) = pl.clips.get_mut(i) { ca.length += ext_a; - ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape }; + ca.fade_out = Fade { len: (ext_a + before).min(ca.length), shape, curvature: 0.0 }; } if let Some(cb) = pl.clips.get_mut(i + 1) { let old = cb.start; cb.trim_start_to(old - ext_b); - cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape }; + cb.fade_in = Fade { len: (ext_b + after).min(cb.length), shape, curvature: 0.0 }; } made += 1; xfaded.push(a.id); @@ -362,10 +362,10 @@ pub fn create_fades(s: &mut Session, track: TrackId, range: Range, shape: FadeSh } for c in pl.clips.iter_mut().filter(|c| !xfaded.contains(&c.id)) { if range.start <= c.start && range.end > c.start && range.end < c.end() { - c.fade_in = Fade { len: range.end - c.start, shape }; + c.fade_in = Fade { len: range.end - c.start, shape, curvature: 0.0 }; made += 1; } else if range.end >= c.end() && range.start > c.start && range.start < c.end() { - c.fade_out = Fade { len: c.end() - range.start, shape }; + c.fade_out = Fade { len: c.end() - range.start, shape, curvature: 0.0 }; made += 1; } } diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index 82690ab3..18995b8a 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -2063,12 +2063,40 @@ mod tests { assert!(out[0][100] > 0.49 && out[1][100].abs() < 1e-4); } + #[test] + fn fade_curvature_reaches_the_rendered_audio() { + for fade_in in [true, false] { + for curvature in [-0.5, 0.0, 0.5] { + let (mut s, t) = session_with_dc(1.0, 8192); + s.track_mut(t).unwrap().mixer.pan = vec![-1.0]; + let fade = soundcraft_model::Fade { len: 8192, shape: soundcraft_model::FadeShape::SCurve, curvature }; + let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0]; + if fade_in { + c.fade_in = fade; + } else { + c.fade_out = fade; + } + let out = render_range(&s, Range::new(0, 8192), 512); + let expected = if curvature < 0.0 { + 0.2 + } else if curvature > 0.0 { + 0.8 + } else { + 0.5 + }; + assert!((out[0][4096] - expected).abs() < 1e-5, "{fade_in}, {curvature}: {} vs {expected}", out[0][4096]); + assert!(out[1][4096].abs() < 1e-6); + assert!(out[0][if fade_in { 0 } else { 8191 }].abs() < 1e-4); + } + } + } + #[test] fn fades_and_clip_gain_apply() { let (mut s, t) = session_with_dc(1.0, 48_000); { let c = &mut s.track_mut(t).unwrap().playlist_mut().unwrap().clips[0]; - c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear }; + c.fade_in = soundcraft_model::Fade { len: 1000, shape: soundcraft_model::FadeShape::Linear, curvature: 0.0 }; c.gain_db = -6.0; } let out = render_range(&s, Range::new(0, 2000), 512); diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 4d30bea1..054cb668 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -47,10 +47,37 @@ impl FadeShape { } /// A fade at a clip boundary, `len` samples long. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)] +#[derive(Debug, Clone, Copy, PartialEq, Default, serde::Serialize, serde::Deserialize)] pub struct Fade { pub len: Samples, pub shape: FadeShape, + /// Continuous bend (-1..1); zero preserves the original shape exactly. + #[serde(default)] + pub curvature: f32, +} + +impl Fade { + /// The envelope used by both rendering and audio, with fixed zero/unity endpoints. + pub fn gain(self, t: f32) -> f32 { + let base = self.shape.gain(t); + let curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + if curvature == 0.0 || base <= 0.0 || base >= 1.0 { + return base; + } + let weighted = base * (curvature * 4.0).exp2(); + weighted / (weighted + 1.0 - base) + } + + /// Invert the bend at the midpoint so a vertical drag follows the pointer. + pub fn curvature_for_midpoint_gain(self, gain: f32) -> f32 { + let gain = if gain.is_finite() { gain.clamp(0.001, 0.999) } else { self.gain(0.5) }; + let base = self.shape.gain(0.5); + (((gain / (1.0 - gain)).log2() - (base / (1.0 - base)).log2()) / 4.0).clamp(-1.0, 1.0) + } + + pub fn sanitize(&mut self) { + self.curvature = if self.curvature.is_finite() { self.curvature.clamp(-1.0, 1.0) } else { 0.0 }; + } } /// What a clip plays. @@ -195,11 +222,11 @@ impl Clip { } let mut g = db_to_gain(db); if self.fade_in.len > 0 && rel < self.fade_in.len { - g *= self.fade_in.shape.gain(rel as f32 / self.fade_in.len as f32); + g *= self.fade_in.gain(rel as f32 / self.fade_in.len as f32); } let from_end = self.length - rel; if self.fade_out.len > 0 && from_end <= self.fade_out.len { - g *= self.fade_out.shape.gain(from_end as f32 / self.fade_out.len as f32); + g *= self.fade_out.gain(from_end as f32 / self.fade_out.len as f32); } g } @@ -264,11 +291,51 @@ mod tests { } } + #[test] + fn fade_curvature_is_bounded_monotonic_and_inverts_at_the_midpoint() { + for shape in FadeShape::ALL { + let original = Fade { len: 1000, shape, curvature: 0.0 }; + for i in 0..=128 { + let t = i as f32 / 128.0; + assert_eq!(original.gain(t), shape.gain(t), "legacy curves must be unchanged"); + } + for curvature in [-1.0, -0.5, 0.0, 0.5, 1.0] { + let fade = Fade { curvature, ..original }; + assert_eq!(fade.gain(0.0), 0.0); + assert_eq!(fade.gain(1.0), 1.0); + let mut previous = 0.0; + for i in 0..=128 { + let gain = fade.gain(i as f32 / 128.0); + assert!(gain.is_finite() && (0.0..=1.0).contains(&gain)); + assert!(gain >= previous, "{shape:?}, {curvature}"); + previous = gain; + } + assert!((fade.curvature_for_midpoint_gain(fade.gain(0.5)) - curvature).abs() < 1e-5); + } + assert!((Fade { curvature: 0.5, ..original }).gain(0.5) > original.gain(0.5)); + assert!((Fade { curvature: -0.5, ..original }).gain(0.5) < original.gain(0.5)); + } + } + + #[test] + fn fade_curvature_loads_legacy_data_and_sanitizes_hostile_values() { + let fade: Fade = serde_json::from_str(r#"{"len":100,"shape":"SCurve"}"#).unwrap(); + assert_eq!(fade.curvature, 0.0); + for curvature in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -10.0, 10.0] { + let mut fade = Fade { curvature, ..fade }; + assert!(fade.gain(0.5).is_finite()); + fade.sanitize(); + assert!(fade.curvature.is_finite() && (-1.0..=1.0).contains(&fade.curvature)); + let roundtrip: Fade = serde_json::from_str(&serde_json::to_string(&fade).unwrap()).unwrap(); + assert_eq!(roundtrip, fade); + } + } + #[test] fn gain_with_fades() { let mut c = Clip::audio(ClipId(1), "a", SourceId(1), 0, 100, 1000); - c.fade_in = Fade { len: 100, shape: FadeShape::Linear }; - c.fade_out = Fade { len: 100, shape: FadeShape::Linear }; + c.fade_in = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; + c.fade_out = Fade { len: 100, shape: FadeShape::Linear, curvature: 0.0 }; assert!(c.gain_at(0).abs() < 1e-6); assert!((c.gain_at(50) - 0.5).abs() < 1e-6); assert!((c.gain_at(500) - 1.0).abs() < 1e-6); @@ -293,7 +360,7 @@ mod tests { assert_eq!(c.length, 1); // Regression: trimming the start must not leave a fade-out longer than the clip. let mut c = Clip::audio(ClipId(2), "b", SourceId(1), 0, 0, 1000); - c.fade_out = Fade { len: 800, shape: FadeShape::Linear }; + c.fade_out = Fade { len: 800, shape: FadeShape::Linear, curvature: 0.0 }; c.trim_start_to(900); assert!(c.fade_out.len <= c.length); } diff --git a/crates/model/src/session.rs b/crates/model/src/session.rs index 59567966..51240be0 100644 --- a/crates/model/src/session.rs +++ b/crates/model/src/session.rs @@ -541,6 +541,8 @@ impl Session { for c in &mut p.clips { c.fade_in.len = c.fade_in.len.clamp(0, c.length); c.fade_out.len = c.fade_out.len.clamp(0, c.length); + c.fade_in.sanitize(); + c.fade_out.sanitize(); if !c.gain_db.is_finite() { c.gain_db = 0.0; } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 3f4247ab..474d32c5 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1296,15 +1296,24 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track if !dim && let Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) = &app.gesture && *id == clip.id - && let Some(range) = fade_drag_range(clip, *side, *to) + && let Some(fade) = fade_drag_preview(clip, *side, *to) { - let fade = soundcraft_model::Fade { len: range.end.saturating_sub(range.start), shape: soundcraft_model::FadeShape::SCurve }; if *side { fade_in = fade; } else { fade_out = fade; } } + if !dim + && let Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) = &app.gesture + && *id == clip.id + { + if *side { + fade_in.curvature = *curvature; + } else { + fade_out.curvature = *curvature; + } + } let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); for (side, fade) in [(true, fade_in), (false, fade_out)] { if fade.len <= 0 { @@ -1315,10 +1324,16 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track let pts: Vec = (0..=16) .map(|i| { let k = i as f32 / 16.0; - pos2(a + (b - a) * k, body_r.max.y - body_r.height() * fade.shape.gain(if side { k } else { 1.0 - k })) + pos2(a + (b - a) * k, body_r.max.y - body_r.height() * fade.gain(if side { k } else { 1.0 - k })) }) .collect(); painter.with_clip_rect(vis).add(Shape::line(pts, Stroke::new(1.2, fade_col))); + let midpoint = pos2((a + b) * 0.5, body_r.max.y - body_r.height() * fade.gain(0.5)); + let active = matches!(app.gesture, Some(Gesture::FadeCurve { clip: id, fade_in, .. }) if id == clip.id && fade_in == side); + let hovered = painter.ctx().input(|i| i.pointer.hover_pos()).is_some_and(|p| near_fade_midpoint(p, midpoint)); + if !dim && s.edit.tool == Tool::Smart && AutoParam::parse(&track.view).is_none() && b - a >= 32.0 && (active || hovered) { + painter.with_clip_rect(vis).circle_filled(midpoint, 3.0, fade_col); + } } // Optional clip overlays (View › Clip). let ov = Color32::from_rgb(16, 16, 16); @@ -1463,6 +1478,32 @@ fn fade_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Op .map(|(fade_in, _)| fade_in) } +fn near_fade_midpoint(p: Pos2, midpoint: Pos2) -> bool { + let delta = p - midpoint; + delta.x.abs() <= 8.0 && delta.y.abs() <= 8.0 +} + +fn fade_midpoint(s: &Session, tl: Rect, lane: Rect, clip: &Clip, fade_in: bool) -> Option { + let fade = if fade_in { clip.fade_in } else { clip.fade_out }; + if fade.len <= 0 || clip.length <= 0 { + return None; + } + let len = fade.len.min(clip.length); + let (a, b) = if fade_in { (clip.start, clip.start.saturating_add(len)) } else { (clip.end().saturating_sub(len), clip.end()) }; + let (a, b) = (x_of(s, tl, a), x_of(s, tl, b)); + if b - a < 32.0 { + return None; + } + let bottom = lane.max.y - 1.0; + let height = lane.height() - 2.0 - clip_name_height(s, lane); + let p = pos2((a + b) * 0.5, bottom - height * fade.gain(0.5)); + lane.contains(p).then_some(p) +} + +fn fade_curve_handle_at(s: &Session, tl: Rect, lane: Rect, clip: &Clip, p: Pos2) -> Option { + [true, false].into_iter().find(|side| fade_midpoint(s, tl, lane, clip, *side).is_some_and(|midpoint| near_fade_midpoint(p, midpoint))) +} + pub const CLIP_FADER_TRAVEL: f32 = 200.0; /// Bottom-left of an audio clip's visible part, when Clip Gain Info is on or the clip has gain and it fits. @@ -1537,7 +1578,12 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } else { None }; - let hit = fade_handle.map(|(c, _)| c).or(body_hit).cloned(); + let curve_handle = if tool == Tool::Smart && auto_view.is_none() && fade_handle.is_none() { + body_hit.and_then(|c| fade_curve_handle_at(&s, tl, lane, c, origin).map(|side| (c, side))) + } else { + None + }; + let hit = fade_handle.or(curve_handle).map(|(c, _)| c).or(body_hit).cloned(); let upper = origin.y < lane.center().y; let near_edge = |c: &Clip| -> Option { let xs = x_of(&s, tl, c.start); @@ -1601,6 +1647,9 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if fade_corner.is_some() && resp.hovered() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); } + if curve_handle.is_some() && resp.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } let gain_icon_at = |q: Pos2| { let c = clip_at(track, sample_at(&s, tl, q.x).max(0)).filter(|_| auto_view.is_none())?; clip_gain_icon(&s, tl, lane, c).filter(|r| r.contains(q)).map(|_| c.clone()) @@ -1608,7 +1657,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, if resp.hovered() && gain_icon_at(p).is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } - let selector_press = eff == Tool::Selector && fade_corner.is_none() && gain_icon_at(origin).is_none(); + let selector_press = eff == Tool::Selector && fade_corner.is_none() && curve_handle.is_none() && gain_icon_at(origin).is_none(); if selector_press && resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.primary_pressed()) { place_insertion(app, ui, &s, track.id, at, mods.shift); } @@ -1624,6 +1673,14 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let fader_bottom = pos2(origin.x.max(screen.min.x + 26.0), y); let fine = mods.command || mods.ctrl; app.gesture = Some(Gesture::ClipGain { clip: c.id, track: track.id, pos, anchor: (origin.y, pos), fine, fader_bottom }); + } else if resp.drag_started() + && let Some((c, fade_in)) = curve_handle + { + let fade = if fade_in { c.fade_in } else { c.fade_out }; + let anchor = (origin.y, fade.gain(0.5)); + let height = (lane.height() - 2.0 - clip_name_height(&s, lane)).max(1.0); + app.gesture = Some(Gesture::FadeCurve { clip: c.id, fade_in, curvature: fade_curve_drag_value(fade, anchor, height, p.y), anchor, height }); + ui.ctx().request_repaint(); } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { @@ -1668,7 +1725,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); clip_gain_popup(ui.ctx(), *fader_bottom, *pos); } - if resp.clicked() { + if resp.clicked() && curve_handle.is_none() { match eff { Tool::Grabber => { if let Some(c) = &hit { @@ -1696,6 +1753,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } } if resp.double_clicked() + && curve_handle.is_none() && eff != Tool::Pencil && let Some(c) = &hit { @@ -1735,6 +1793,20 @@ fn fade_drag_range(clip: &Clip, fade_in: bool, to: Samples) -> Option { }) } +fn fade_drag_preview(clip: &Clip, fade_in: bool, to: Samples) -> Option { + let range = fade_drag_range(clip, fade_in, to)?; + let original = if fade_in { clip.fade_in } else { clip.fade_out }; + Some(soundcraft_model::Fade { + len: range.end.saturating_sub(range.start), + shape: if original.len > 0 { original.shape } else { soundcraft_model::FadeShape::SCurve }, + curvature: if original.len > 0 { original.curvature } else { 0.0 }, + }) +} + +fn fade_curve_drag_value(fade: soundcraft_model::Fade, anchor: (f32, f32), height: f32, y: f32) -> f32 { + fade.curvature_for_midpoint_gain(anchor.1 + (anchor.0 - y) / height.max(1.0)) +} + /// Runs before anything is painted, against the last frame's layout. fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { if app.gesture.is_none() || matches!(app.gesture, Some(Gesture::Fader { .. })) { @@ -1783,6 +1855,18 @@ fn drive_drag(app: &mut SoundApp, ctx: &egui::Context) { invalid_fade = true; } } + Some(Gesture::FadeCurve { clip, fade_in, curvature, anchor, height }) => { + if let Some((_, c)) = s.find_clip(*clip).filter(|(_, c)| c.length > 0) { + let fade = if *fade_in { c.fade_in } else { c.fade_out }; + if fade.len > 0 { + *curvature = fade_curve_drag_value(fade, *anchor, *height, p.y); + } else { + invalid_fade = true; + } + } else { + invalid_fade = true; + } + } Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1844,13 +1928,25 @@ fn finish_drag(app: &mut SoundApp, s: &Session, tl: Rect, p: Option) { } Some(Gesture::Fade { clip, fade_in, to, .. }) => { let to = p.map_or(to, |p| sample_at(s, tl, p.x).max(0)); - if let Some(range) = s.find_clip(clip).and_then(|(_, c)| fade_drag_range(c, fade_in, to)) { + if let Some(fade) = s.find_clip(clip).and_then(|(_, c)| fade_drag_preview(c, fade_in, to)) { let _ = app.run( "edit.fades_create", - json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": range.end.saturating_sub(range.start), "shape": "s-curve"}), + json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": fade.len, "shape": fade.shape.label(), "curvature": fade.curvature}), ); } } + Some(Gesture::FadeCurve { clip, fade_in, curvature, anchor, height }) => { + if let Some((_, c)) = s.find_clip(clip).filter(|(_, c)| c.length > 0) { + let fade = if fade_in { c.fade_in } else { c.fade_out }; + let curvature = p.map_or(curvature, |p| fade_curve_drag_value(fade, anchor, height, p.y)); + if fade.len > 0 && (fade.curvature - curvature).abs() > 1e-6 { + let _ = app.run( + "edit.fades_create", + json!({"clip": clip.0, "side": if fade_in { "in" } else { "out" }, "length": fade.len, "shape": fade.shape.label(), "curvature": curvature}), + ); + } + } + } Some(Gesture::TrimStart { clip, track, to }) => trim_clip(app, clip, track, to, true), Some(Gesture::TrimEnd { clip, track, to }) => trim_clip(app, clip, track, to, false), Some(Gesture::Pencil { track, points }) => { @@ -2068,6 +2164,8 @@ fn overlay(app: &mut SoundApp, ui: &mut Ui, tl: Rect, area: Rect) { } if matches!(app.gesture, Some(Gesture::Fade { .. })) { ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeNwSe); + } else if matches!(app.gesture, Some(Gesture::FadeCurve { .. })) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); } } @@ -2483,7 +2581,7 @@ mod tests { let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, release.x)).unwrap(); let mut expected = before.clone(); let c = expected.find_clip_mut(clip.id).unwrap(); - let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve }; + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; if fade_in { c.fade_in = fade; } else { @@ -2513,8 +2611,8 @@ mod tests { let tl = timeline(&h); let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - c.fade_in = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear }; - c.fade_out = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::EqualPower }; + c.fade_in = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: 0.0 }; + c.fade_out = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::EqualPower, curvature: 0.0 }; h.step(); let s = h.state().engine.session(); let (xs, xe) = (x_of(s, tl, clip.start), x_of(s, tl, clip.end())); @@ -2550,6 +2648,125 @@ mod tests { } } + #[test] + fn fade_midpoints_drag_vertically_without_moving_the_endpoints() { + for fade_in in [true, false] { + for show_name in [true, false] { + for dy in [-16.0, 16.0] { + let mut h = smart_demo(); + h.state_mut().engine.execute("view.clip_name", &json!({"value": show_name})).unwrap(); + h.step(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + let target = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + let other = soundcraft_model::Fade { len: c.length / 8, shape: soundcraft_model::FadeShape::Linear, curvature: -0.2 }; + (c.fade_in, c.fade_out) = if fade_in { (target, other) } else { (other, target) }; + h.step(); + let before = h.state().engine.session().clone(); + let clip_rect = + Rect::from_min_max(pos2(x_of(&before, tl, clip.start), lane.min.y), pos2(x_of(&before, tl, clip.end()), lane.max.y)); + let original = fade_curves(&h, clip_rect); + let (edited, opposite) = if fade_in { (0, 1) } else { (1, 0) }; + let midpoint = original[edited].0.points[8]; + let undo_len = h.state().engine.undo_history().len(); + h.hover_at(midpoint); + h.step(); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + click(&mut h, midpoint); + assert_eq!(h.state().engine.session(), &before, "clicking the curve control should not change the selection"); + h.drag_at(midpoint); + let to = midpoint + vec2(0.0, dy); + h.hover_at(to); + h.step(); + h.step(); // Paint the frame requested when the drag gesture starts. + let curvature = match h.state().gesture { + Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if id == clip.id && side == fade_in => curvature, + _ => panic!("the midpoint must start a curve gesture"), + }; + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeVertical); + assert!(if dy < 0.0 { curvature > 0.0 } else { curvature < 0.0 }); + let preview = fade_curves(&h, clip_rect); + assert_eq!(preview.len(), 2); + assert_eq!(preview[opposite], original[opposite]); + assert_eq!(preview[edited].0.points.first(), original[edited].0.points.first()); + assert_eq!(preview[edited].0.points.last(), original[edited].0.points.last()); + assert_eq!(preview[edited].0.points[8].x, midpoint.x); + assert!( + (preview[edited].0.points[8].y - (midpoint.y + dy)).abs() < 0.001, + "side={fade_in}, names={show_name}, dy={dy}, midpoint={midpoint:?}, drawn={:?}, gesture={:?}", + preview[edited].0.points[8], + h.state().gesture + ); + assert_eq!(h.state().engine.session(), &before); + let side = if fade_in { "in" } else { "out" }; + let direction = if dy < 0.0 { "up" } else { "down" }; + if show_name { + fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-preview")); + } + h.drop_at(to); + h.step(); + let mut expected = before.clone(); + let c = expected.find_clip_mut(clip.id).unwrap(); + if fade_in { + c.fade_in.curvature = curvature; + } else { + c.fade_out.curvature = curvature; + } + assert_eq!(h.state().engine.session(), &expected); + assert_eq!(fade_curves(&h, clip_rect), preview, "the released envelope must match the preview"); + let rel = if fade_in { target.len / 2 } else { clip.length - target.len / 2 }; + let previous_gain = before.find_clip(clip.id).unwrap().1.gain_at(rel); + let new_gain = expected.find_clip(clip.id).unwrap().1.gain_at(rel); + assert!(if dy < 0.0 { new_gain > previous_gain } else { new_gain < previous_gain }); + assert_eq!(h.state().engine.undo_history().len(), undo_len + 1); + if show_name { + fade_shot(&mut h, &format!("fade-{side}-bend-{direction}-released")); + } + assert!(h.state_mut().engine.undo()); + assert_eq!(h.state().engine.session(), &before); + assert!(h.state_mut().engine.redo()); + assert_eq!(h.state().engine.session(), &expected); + } + } + } + } + + #[test] + fn fade_curve_drags_cancel_safely_or_return_to_the_original_curve() { + for invalidate in [0, 1, 2] { + let mut h = smart_demo(); + let tl = timeline(&h); + let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); + let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); + c.fade_out = soundcraft_model::Fade { len: c.length * 2 / 3, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; + h.step(); + let midpoint = + fade_midpoint(h.state().engine.session(), tl, lane, h.state().engine.session().find_clip(clip.id).unwrap().1, false).unwrap(); + let before = h.state().engine.session().clone(); + let undo_len = h.state().engine.undo_history().len(); + start_drag(&mut h, midpoint); + h.hover_at(midpoint - vec2(0.0, 16.0)); + h.step(); + assert!(matches!(h.state().gesture, Some(Gesture::FadeCurve { .. }))); + if invalidate == 1 { + h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap().length = 0; + } else if invalidate == 2 { + let (tid, _) = before.find_clip(clip.id).unwrap(); + h.state_mut().engine.session_mut().track_mut(tid).unwrap().playlist_mut().unwrap().clips.retain(|c| c.id != clip.id); + } + h.hover_at(midpoint); + h.step(); + h.drop_at(midpoint); + h.step(); + assert!(h.state().gesture.is_none()); + assert_eq!(h.state().engine.undo_history().len(), undo_len); + if invalidate == 0 { + assert_eq!(h.state().engine.session(), &before); + } + } + } + #[test] fn existing_fades_drag_from_their_upper_curve_endpoints() { for fade_in in [true, false] { @@ -2562,7 +2779,7 @@ mod tests { let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); let len = if full_length { clip.length } else { clip.length * 2 / 3 }; let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - let fade = soundcraft_model::Fade { len, shape: soundcraft_model::FadeShape::SCurve }; + let fade = soundcraft_model::Fade { len, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.35 }; if fade_in { c.fade_in = fade; } else { @@ -2609,7 +2826,7 @@ mod tests { let range = fade_drag_range(&clip, fade_in, sample_at(&before, tl, to.x)).unwrap(); let mut expected = before.clone(); let c = expected.find_clip_mut(clip.id).unwrap(); - let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve }; + let fade = soundcraft_model::Fade { len: range.end - range.start, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.35 }; assert!(fade.len < len); if fade_in { c.fade_in = fade; @@ -2631,7 +2848,7 @@ mod tests { let tl = timeline(&h); let (clip, lane) = clip_on_screen(&h, |xs, xe| xs > tl.min.x + 30.0 && xe < tl.max.x - 100.0 && xe - xs > 100.0); let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap(); - c.fade_out = soundcraft_model::Fade { len: c.length / 2, shape: soundcraft_model::FadeShape::SCurve }; + c.fade_out = soundcraft_model::Fade { len: c.length / 2, shape: soundcraft_model::FadeShape::SCurve, curvature: 0.0 }; h.step(); let s = h.state().engine.session(); let rect = Rect::from_min_max(pos2(x_of(s, tl, clip.start), lane.min.y), pos2(x_of(s, tl, clip.end()), lane.max.y)); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5c4a7514..9a63a531 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -209,6 +209,7 @@ pub enum Gesture { TrimStart { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, TrimEnd { clip: soundcraft_model::ClipId, track: TrackId, to: Samples }, Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples }, + FadeCurve { clip: soundcraft_model::ClipId, fade_in: bool, curvature: f32, anchor: (f32, f32), height: f32 }, Scrub { last: Samples }, Fader { track: TrackId, start_db: f32 }, ClipGain { clip: soundcraft_model::ClipId, track: TrackId, pos: f32, anchor: (f32, f32), fine: bool, fader_bottom: egui::Pos2 }, diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 51495022..0b13427d 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File-format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first format inventory against Pro Tools 2026.4.1) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (continuous fade curvature in SoundCraft sessions) · **Target:** Avid Pro Tools Ultimate 2026.4.1 Every format Pro Tools reads or writes, and ours. The Pro Tools list comes from the document types its bundle declares (`CFBundleDocumentTypes` in Info.plist: names only, nothing else read) and its @@ -23,7 +23,7 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | OMF | import/export | ❌ | ❌ | Legacy, still delivered by some editors | | Media Composer–compatible session | export | ❌ | ❌ | AAF variant | | ADM BWF (Dolby Atmos master) | import/export | ❌ | ❌ | G-12 | -| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded | +| SoundCraft session `.scraft` + `Audio Files/` | — | ✅ | ✅ | JSON (serde); mutated-file property test loads or fails cleanly; plugin state embedded. Fades save optional `curvature` (-1..1); missing values default to 0 and preserve legacy envelopes | | Import Session Data (from another session) | Pro Tools sessions | 🟡 | — | From `.scraft` only | | Session info as text | export | — | ✅ | `file.export_session_text` | | Clip groups `.rgrp` / ours `.scgrp` | read/write | 🟡 | 🟡 | Our own `.scgrp` format; Pro Tools' clip-group files not read | @@ -80,4 +80,5 @@ Legend: ✅ supported and tested · 🟡 partial · ❌ missing · — not appli | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Fade curvature round-trips in sessions with a zero default for older files. Format version and readiness estimate unchanged | | 2026-10-10 | major | Created: format inventory against Pro Tools 2026.4.1's declared document types and import/bounce options | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 3e8549c0..5ca585f4 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (existing fades can be dragged from their upper endpoints) · **Target:** Avid Pro Tools Ultimate 2026.4.1 +> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (fade midpoints adjust continuous curvature) · **Target:** Avid Pro Tools Ultimate 2026.4.1 How SoundCraft feels in the hands of a Pro Tools user: tools, modes, modifiers, shortcuts, windows, meters. Measured from `crates/model/src/session.rs` (tools, modes), the command @@ -17,6 +17,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. |---|---|---| | Tools: Zoom, Trim, Selector, Grabber, Scrubber, Pencil, Smart (7) | ✅ present | Trim variants (TCE, loop, scrub trim) and Grabber variants (separation, object) partial; Multitool fade/trim handles don't adjust audio (#5) | | Smart-tool Fade In / Fade Out drag bounds and preview | ✅ | One thin curve replaces the dragged side's previous curve and matches its appearance after release; bounded to the clip, including full-clip fades. Hovering the curve's upper endpoint shows the fade cursor and lets the existing fade be dragged | +| Fade midpoint curvature | ✅ | Hovering the middle of a visible fade shows a small handle and vertical resize cursor. Dragging up/down bends the envelope without changing its length; preview, playback and bounce share the curve. Length drags retain the chosen curvature | | Edit modes: Shuffle, Slip, Spot, Grid (absolute/relative) | ✅ | Spot dialog present | | Grid and nudge values, all timebases | ✅ | Bars/Beats, Min:Secs, Timecode, Feet+Frames, Samples | | Keyboard focus modes (Commands, Clip List, Groups) | 🟡 | Commands focus single-key editing ✅; list focus partial | @@ -65,6 +66,7 @@ Legend: ✅ matches · 🟡 partial · ❌ missing. | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | Continuous fade curvature from a midpoint drag, shared UI/audio envelope, one-step undo and saved session data. Readiness estimate unchanged | | 2026-10-13 | minor | Existing fades have draggable upper endpoints with matching hover/drag cursors, including at adjacent clip boundaries. Readiness estimate unchanged | | 2026-10-13 | minor | Fade drags use the same thin S-curve renderer as committed fades, replacing the dragged side's old curve. Readiness estimate unchanged | | 2026-10-13 | minor | Bounded Smart-tool fade previews and commits; full-clip fades target only the dragged clip and side. Readiness estimate unchanged |