This commit is contained in:
Brandon Thomas committed 2026-10-13 06:00:30 -04:00
commit 6583ec6032
10 files changed
+1293 -81

No files matched your search

+161 -4
View File
@@ -5,7 +5,7 @@ use super::*;
use crate::edit as ops;
use crate::{Clipboard, cmd};
use serde_json::json;
use soundcraft_model::{EditMode, FadeShape, Session};
use soundcraft_model::{EditMode, Fade, FadeShape, Session};
pub fn specs() -> Vec<CommandSpec> {
vec![
@@ -126,7 +126,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Create...",
["Edit", "Fades"],
Some("Cmd+F"),
"{shape?: linear|equal power|s-curve|exponential|logarithmic, tracks?, start?, end?}",
"{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
),
@@ -801,11 +801,53 @@ fn strip_silence(e: &mut Engine, p: &Value) -> Result<Value> {
}
fn fades_create(e: &mut Engine, p: &Value) -> Result<Value> {
let tracks = tracks_required(e, "edit.fades_create", p)?;
let r = range_param(e, "edit.fades_create", p)?;
let shape = str_param(p, "shape")
.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"));
}
let id = p.get("clip").and_then(Value::as_u64).map(soundcraft_model::ClipId).ok_or_else(|| bad("edit.fades_create", "clip must be an id"))?;
let fade_in = match str_param(p, "side") {
Some("in") => true,
Some("out") => false,
_ => return Err(bad("edit.fades_create", "side must be in or out")),
};
let length = p
.get("length")
.and_then(Value::as_i64)
.filter(|len| *len > 0)
.ok_or_else(|| bad("edit.fades_create", "length must be a positive sample count"))?;
let (_, clip) = e.session().find_clip(id).ok_or_else(|| bad("edit.fades_create", "clip not found"))?;
let available = clip.length.min(clip.end().saturating_sub(clip.start));
if available <= 0 {
return Err(bad("edit.fades_create", "clip has no length"));
}
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, 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;
} else {
clip.fade_out = fade;
}
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();
let n: usize = tracks.iter().map(|t| ops::create_fades(s, *t, r, shape)).sum();
if n == 0 {
@@ -925,6 +967,121 @@ mod tests {
use super::*;
use soundcraft_model::{ChannelFormat, Clip, ClipContent, TrackId, TrackKind};
#[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"] {
let mut e = crate::demo::demo_engine();
let clip = e.session().track_by_name("Kick").unwrap().clips()[0].clone();
e.execute("edit.select_none", &json!({})).unwrap();
let before = e.session().clone();
let undo_len = e.undo_history().len();
assert_eq!(
e.execute("edit.fades_create", &json!({"clip": clip.id.0, "side": side, "length": i64::MAX, "shape": "s-curve"})).unwrap(),
json!({"fades": 1})
);
let mut expected = before.clone();
let c = expected.find_clip_mut(clip.id).unwrap();
let fade = Fade { len: clip.length, shape: FadeShape::SCurve, curvature: 0.0 };
if side == "in" {
c.fade_in = fade;
} else {
c.fade_out = fade;
}
assert_eq!(e.session(), &expected);
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_reject_invalid_parameters_without_an_edit() {
let mut e = crate::demo::demo_engine();
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id.0;
for params in [
json!({"clip": id, "side": "in"}),
json!({"clip": id, "length": 10}),
json!({"side": "out", "length": 10}),
json!({"clip": "bad", "side": "in", "length": 10}),
json!({"clip": u64::MAX, "side": "in", "length": 10}),
json!({"clip": id, "side": "bad", "length": 10}),
json!({"clip": id, "side": "in", "length": 0}),
json!({"clip": id, "side": "in", "length": -1}),
json!({"clip": id, "side": "in", "length": 1.5}),
json!({"clip": id, "side": "in", "length": "10"}),
json!({"clip": id, "side": "in", "length": u64::MAX}),
json!({"clip": id, "side": "in", "length": 10, "shape": "bad"}),
json!({"clip": id, "side": "in", "length": 10, "shape": null}),
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();
assert!(e.execute("edit.fades_create", &params).is_err(), "{params}");
assert_eq!(e.session(), &before, "{params}");
assert_eq!(e.undo_history(), history, "{params}");
}
e.session_mut().find_clip_mut(soundcraft_model::ClipId(id)).unwrap().length = 0;
let before = e.session().clone();
assert!(e.execute("edit.fades_create", &json!({"clip": id, "side": "in", "length": 1})).is_err());
assert_eq!(e.session(), &before);
}
#[test]
fn targeted_fades_support_one_sample_clips() {
let mut e = crate::demo::demo_engine();
let id = e.session().track_by_name("Kick").unwrap().clips()[0].id;
e.session_mut().find_clip_mut(id).unwrap().length = 1;
for side in ["in", "out"] {
e.execute("edit.fades_create", &json!({"clip": id.0, "side": side, "length": 1})).unwrap();
let clip = e.session().find_clip(id).unwrap().1;
assert_eq!(if side == "in" { clip.fade_in.len } else { clip.fade_out.len }, 1);
}
}
#[test]
fn selecting_what_is_already_selected_leaves_the_document_alone() {
let mut e = Engine::default();
+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
@@ -771,12 +771,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);
@@ -785,10 +785,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
@@ -2322,6 +2322,34 @@ 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 pan_automation_does_not_depend_on_the_block_size() {
// Bounces render in 1024-frame blocks, playback in whatever its callback asks for (#244).
@@ -2343,7 +2371,7 @@ mod tests {
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);
+116 -10
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.
@@ -194,17 +221,23 @@ impl Clip {
/// Gain (linear) at `rel` samples from the clip start: clip gain × envelope × fades.
pub fn gain_at(&self, rel: Samples) -> f32 {
self.gain_at_with_fades(rel, self.fade_in, self.fade_out)
}
/// Evaluate temporary fades while retaining clip gain and its envelope.
/// UI previews can use this without modifying the clip or rebuilding media.
pub fn gain_at_with_fades(&self, rel: Samples, fade_in: Fade, fade_out: Fade) -> f32 {
let mut db = self.gain_db;
if !self.gain_env.is_empty() {
db += env_value(&self.gain_env, rel);
}
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);
if fade_in.len > 0 && rel < fade_in.len {
g *= fade_in.gain(rel as f32 / 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);
let from_end = self.length.saturating_sub(rel);
if fade_out.len > 0 && from_end <= fade_out.len {
g *= fade_out.gain(from_end as f32 / fade_out.len as f32);
}
g
}
@@ -265,6 +298,8 @@ impl Clip {
ClipContent::Midi { sequence } => bound_sequence(sequence),
}
self.clamp_fades();
self.fade_in.sanitize();
self.fade_out.sanitize();
truncate_chars(&mut self.name, MAX_NAME_CHARS);
if !self.gain_db.is_finite() {
self.gain_db = 0.0;
@@ -292,7 +327,11 @@ fn env_value(env: &[(Samples, f32)], at: Samples) -> f32 {
for &(t, v) in env {
if t >= at {
return match prev {
Some((pt, pv)) if t > pt => pv + (v - pv) * ((at - pt) as f32 / (t - pt) as f32),
Some((pt, pv)) if t > pt => {
let elapsed = i128::from(at).saturating_sub(i128::from(pt));
let span = i128::from(t).saturating_sub(i128::from(pt));
pv + (v - pv) * (elapsed as f32 / span as f32)
}
_ => v,
};
}
@@ -319,11 +358,78 @@ 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 temporary_fades_keep_clip_gain_and_envelope_without_mutating_the_clip() {
let mut clip = Clip::audio(ClipId(1), "preview", SourceId(1), 0, 0, 1000);
clip.gain_db = -6.0;
clip.gain_env = vec![(0, 0.0), (1000, 6.0)];
clip.fade_in = Fade { len: 100, shape: FadeShape::SCurve, curvature: 0.25 };
clip.fade_out = Fade { len: 100, shape: FadeShape::Logarithmic, curvature: -0.1 };
let before = clip.clone();
let fade_in = Fade { len: 1000, shape: FadeShape::Linear, curvature: 0.5 };
let fade_out = Fade { len: 1000, shape: FadeShape::Linear, curvature: -0.5 };
assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6);
let mut committed = clip.clone();
committed.fade_in = fade_in;
committed.fade_out = fade_out;
for rel in [0, 50, 500, 999, 1000] {
assert_eq!(clip.gain_at_with_fades(rel, fade_in, fade_out), committed.gain_at(rel));
}
for rel in [i64::MIN, i64::MAX] {
assert!(clip.gain_at(rel).is_finite());
assert!(clip.gain_at_with_fades(rel, fade_in, fade_out).is_finite());
}
assert_eq!(clip, before);
clip.gain_env = vec![(i64::MIN, 0.0), (i64::MAX, 6.0)];
assert!((clip.gain_at(500) - db_to_gain(-3.0)).abs() < 1e-6);
assert!((clip.gain_at_with_fades(500, fade_in, fade_out) - db_to_gain(-3.0) * 0.16).abs() < 1e-6);
}
#[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);
@@ -369,7 +475,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);
}
File diff suppressed because it is too large. Load diff
+1
View File
@@ -223,6 +223,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 },
+2 -1
View File
@@ -502,7 +502,8 @@ mod tests {
drop(inner);
drop(outer);
drop(app);
assert!(rx.recv_timeout(std::time::Duration::from_secs(5)).is_ok(), "the palette lock must be released when every holder drops it");
// Other tests compete for the lock too, and some hold it for seconds: allow for a queue.
assert!(rx.recv_timeout(std::time::Duration::from_secs(120)).is_ok(), "the palette lock must be released when every holder drops it");
}
#[test]
+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 |
+10 -1
View File
@@ -1,6 +1,6 @@
# UI parity
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (first interaction checklist 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 (waveforms preview fades during drag) · **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
@@ -16,6 +16,9 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Feature | Status | Notes |
|---|---|---|
| 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 | ✅ | Visible fades retain a small midpoint handle in Smart mode, including after release and extreme bends. Hover shows the vertical resize cursor; repeated drags change curvature without changing length. Curves sample at physical-pixel resolution; preview, playback and bounce share the envelope. Length drags retain curvature |
| Live waveform fade preview | ✅ | Waveform amplitude updates during fade-length and midpoint-curvature drags, sharing the curve's temporary envelope. Mono/stereo, waveform styles, zoom and clip gain remain effective; release preserves the displayed waveform with one undo step and cached peaks |
| 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 |
@@ -64,4 +67,10 @@ Legend: ✅ matches · 🟡 partial · ❌ missing.
| Date | Change | Summary |
|---|---|---|
| 2026-10-13 | minor | Waveforms and fade curves share temporary envelopes during drag, without rebuilding media or editing the document before release. Readiness estimate unchanged |
| 2026-10-13 | minor | Fade curves use bounded physical-pixel sampling; midpoint handles stay visible and inside the clip, with repeat-drag coverage after extreme bends. Readiness estimate unchanged |
| 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 |
| 2026-10-10 | major | Created: tools, modes, shortcuts, windows and meters checklist against Pro Tools 2026.4.1 |