Add draggable midpoint curvature for clip fades

This commit is contained in:
reoniazx committed 2026-10-13 02:48:00 +08:00
1 parent d5e6fa32ef
commit 4ac47b3a7f
10 files changed
+400 -32

No files matched your search

+53 -3
View File
@@ -118,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
.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<Value> {
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<Value> {
}
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<Value> {
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();
+1 -1
View File
@@ -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
};
+4 -4
View File
@@ -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;
}
}
+29 -1
View File
@@ -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);
+73 -6
View File
@@ -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);
}
+2
View File
@@ -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;
}
+231 -14
View File
@@ -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<Pos2> = (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<Pos2> {
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<bool> {
[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<bool> {
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<Range> {
})
}
fn fade_drag_preview(clip: &Clip, fade_in: bool, to: Samples) -> Option<soundcraft_model::Fade> {
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<Pos2>) {
}
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));
+1
View File
@@ -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 },
+3 -2
View File
@@ -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 |
+3 -1
View File
@@ -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 |