mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-15 06:46:45 -04:00
Add draggable midpoint curvature for clip fades
This commit is contained in:
1 parent
d5e6fa32ef
commit
4ac47b3a7f
10 files changed
+400
-32
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in new issue
Block a user