Preview waveform amplitudes during fade drags
This commit is contained in:
parent
79e30c39d8
commit
5ec8c21b19
|
|
@ -216,17 +216,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.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.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
|
||||
}
|
||||
|
|
@ -264,7 +270,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,
|
||||
};
|
||||
}
|
||||
|
|
@ -331,6 +341,33 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
|
|
|
|||
|
|
@ -1172,6 +1172,30 @@ fn fade_control_position(fade: soundcraft_model::Fade, a: f32, b: f32, body: Rec
|
|||
pos2((a + b) * 0.5, y.max(body.min.y + padding).min(body.max.y - padding))
|
||||
}
|
||||
|
||||
fn clip_preview_fades(clip: &Clip, gesture: Option<&Gesture>) -> (soundcraft_model::Fade, soundcraft_model::Fade) {
|
||||
let (mut fade_in, mut fade_out) = (clip.fade_in, clip.fade_out);
|
||||
match gesture {
|
||||
Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) if *id == clip.id => {
|
||||
if let Some(fade) = fade_drag_preview(clip, *side, *to) {
|
||||
if *side {
|
||||
fade_in = fade;
|
||||
} else {
|
||||
fade_out = fade;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Gesture::FadeCurve { clip: id, fade_in: side, curvature, .. }) if *id == clip.id => {
|
||||
if *side {
|
||||
fade_in.curvature = *curvature;
|
||||
} else {
|
||||
fade_out.curvature = *curvature;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
(fade_in, fade_out)
|
||||
}
|
||||
|
||||
fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track, clip: &Clip, tl: Rect, lane: Rect, dim: bool) {
|
||||
let x0 = x_of(s, tl, clip.start);
|
||||
let x1 = x_of(s, tl, clip.end()).max(x0 + 1.0);
|
||||
|
|
@ -1204,6 +1228,8 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
|||
}
|
||||
let body_r = Rect::from_min_max(pos2(r.min.x, r.min.y + name_h), r.max);
|
||||
let vis = body_r.intersect(lane);
|
||||
// The waveform and fade curve share the same temporary envelope every frame.
|
||||
let (fade_in, fade_out) = clip_preview_fades(clip, app.gesture.as_ref().filter(|_| !dim));
|
||||
match &clip.content {
|
||||
ClipContent::Audio { source, offset } => {
|
||||
if let Some(audio) = s.pool.get(*source) {
|
||||
|
|
@ -1226,7 +1252,7 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
|||
for (i, (mn, mx)) in colsv.iter().enumerate() {
|
||||
let x = vis.min.x + i as f32;
|
||||
let rel = sample_at(s, tl, x) - clip.start;
|
||||
let g = clip.gain_at(rel.clamp(0, clip.length));
|
||||
let g = clip.gain_at_with_fades(rel.clamp(0, clip.length), fade_in, fade_out);
|
||||
let (mut top, mut bot) = if rectified {
|
||||
let a = (mx.abs().max(mn.abs()) * g * zoom).clamp(0.0, 1.0);
|
||||
let b = cy + half;
|
||||
|
|
@ -1313,29 +1339,6 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track
|
|||
painter.rect_filled(Rect::from_center_size(pos2(icon.center().x, icon.center().y + 2.0), vec2(icon.width(), 3.0)), 1.0, col);
|
||||
painter.text(pos2(icon.max.x + 3.0, icon.center().y), Align2::LEFT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col);
|
||||
}
|
||||
// Preview replaces the dragged fade in the same renderer used after release.
|
||||
let (mut fade_in, mut fade_out) = (clip.fade_in, clip.fade_out);
|
||||
if !dim
|
||||
&& let Some(Gesture::Fade { clip: id, fade_in: side, to, .. }) = &app.gesture
|
||||
&& *id == clip.id
|
||||
&& let Some(fade) = fade_drag_preview(clip, *side, *to)
|
||||
{
|
||||
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 {
|
||||
|
|
@ -1707,6 +1710,7 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect,
|
|||
fade_in,
|
||||
to: if fade_in { range.end } else { range.start },
|
||||
});
|
||||
ui.ctx().request_repaint();
|
||||
} else if resp.drag_started() {
|
||||
app.gesture = match eff {
|
||||
Tool::Selector | Tool::Smart => Some(Gesture::Select { track: track.id, anchor: snap(&s, at_origin), tracks: vec![track.id] }),
|
||||
|
|
@ -2557,6 +2561,147 @@ mod tests {
|
|||
.collect()
|
||||
}
|
||||
|
||||
fn waveform_geometry(h: &Harness<SoundApp>, clip_rect: Rect) -> Vec<Vec<Pos2>> {
|
||||
h.output()
|
||||
.shapes
|
||||
.iter()
|
||||
.filter_map(|shape| match &shape.shape {
|
||||
Shape::Mesh(mesh) if !mesh.vertices.is_empty() && mesh.vertices.iter().all(|vertex| clip_rect.contains(vertex.pos)) => {
|
||||
Some(mesh.vertices.iter().map(|vertex| vertex.pos).collect())
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn waveforms_follow_fade_drags_before_release_and_match_the_committed_waveform() {
|
||||
for channels in [1, 2] {
|
||||
for fade_in in [true, false] {
|
||||
for bend in [false, true] {
|
||||
for mode in ["peak", "power", "rectified", "outlines"] {
|
||||
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 (tid, _) = h.state().engine.session().find_clip(clip.id).unwrap();
|
||||
let neighbour = h
|
||||
.state()
|
||||
.engine
|
||||
.session()
|
||||
.track(tid)
|
||||
.unwrap()
|
||||
.clips()
|
||||
.iter()
|
||||
.find(|c| c.id != clip.id && c.start >= clip.end())
|
||||
.unwrap()
|
||||
.clone();
|
||||
let source = clip.source().unwrap();
|
||||
if channels == 2 {
|
||||
let audio = h.state().engine.session().pool.get(source).unwrap().clone();
|
||||
let left = audio.buffer.channels[0].clone();
|
||||
let right = left.iter().map(|sample| sample * 0.5).collect();
|
||||
let audio = soundcraft_model::SourceAudio::new(soundcraft_audio_io::AudioBuffer {
|
||||
sample_rate: audio.buffer.sample_rate,
|
||||
channels: vec![left, right],
|
||||
});
|
||||
let s = h.state_mut().engine.session_mut();
|
||||
s.pool.insert(source, std::sync::Arc::new(audio));
|
||||
s.track_mut(tid).unwrap().format = soundcraft_model::ChannelFormat::Stereo;
|
||||
}
|
||||
if mode == "power" {
|
||||
h.state_mut().engine.execute("view.waveform_power", &json!({})).unwrap();
|
||||
}
|
||||
h.state_mut().engine.execute("view.waveform_rectified", &json!({"value": mode == "rectified"})).unwrap();
|
||||
h.state_mut().engine.execute("view.waveform_outlines", &json!({"value": mode == "outlines"})).unwrap();
|
||||
h.state_mut().engine.execute("view.waveform_zoom", &json!({"value": 1.4})).unwrap();
|
||||
let c = h.state_mut().engine.session_mut().find_clip_mut(clip.id).unwrap();
|
||||
c.gain_db = -3.0;
|
||||
c.gain_env = vec![(0, -3.0), (c.length, 3.0)];
|
||||
let target = soundcraft_model::Fade {
|
||||
len: if bend { c.length * 2 / 3 } else { c.length / 4 },
|
||||
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 undo_len = h.state().engine.undo_history().len();
|
||||
let audio = before.pool.get(source).unwrap().clone();
|
||||
let (xs, xe) = (x_of(&before, tl, clip.start), x_of(&before, tl, clip.end()));
|
||||
let clip_rect = Rect::from_min_max(pos2(xs, lane.min.y), pos2(xe, lane.max.y)).intersect(tl);
|
||||
let neighbour_rect = Rect::from_min_max(
|
||||
pos2(x_of(&before, tl, neighbour.start), lane.min.y),
|
||||
pos2(x_of(&before, tl, neighbour.end()), lane.max.y),
|
||||
)
|
||||
.intersect(tl);
|
||||
let original = waveform_geometry(&h, clip_rect);
|
||||
let neighbour_wave = waveform_geometry(&h, neighbour_rect);
|
||||
assert_eq!(original.len(), channels);
|
||||
let side = if fade_in { "in" } else { "out" };
|
||||
let operation = if bend { "bend" } else { "length" };
|
||||
let screenshot = channels == 1 && mode == "peak";
|
||||
if screenshot {
|
||||
fade_shot(&mut h, &format!("waveform-{side}-{operation}-before"));
|
||||
}
|
||||
let (press, to) = if bend {
|
||||
let c = before.find_clip(clip.id).unwrap().1;
|
||||
let midpoint = fade_midpoint(&before, tl, lane, c, fade_in).unwrap();
|
||||
(midpoint, midpoint - vec2(0.0, 16.0))
|
||||
} else {
|
||||
(
|
||||
pos2(if fade_in { xs + 6.0 } else { xe - 6.0 }, lane.min.y + 4.0),
|
||||
pos2(xs + (xe - xs) * if fade_in { 0.7 } else { 0.3 }, lane.min.y + 4.0),
|
||||
)
|
||||
};
|
||||
h.hover_at(press);
|
||||
h.drag_at(press);
|
||||
h.hover_at(to);
|
||||
h.step();
|
||||
h.step();
|
||||
assert!(if bend {
|
||||
matches!(h.state().gesture, Some(Gesture::FadeCurve { .. }))
|
||||
} else {
|
||||
matches!(h.state().gesture, Some(Gesture::Fade { .. }))
|
||||
});
|
||||
let preview = waveform_geometry(&h, clip_rect);
|
||||
assert_eq!(preview.len(), channels);
|
||||
for channel in 0..channels {
|
||||
assert_ne!(
|
||||
preview[channel], original[channel],
|
||||
"the waveform must respond while the button is held: {side}, {operation}, {mode}, channel={channel}"
|
||||
);
|
||||
}
|
||||
assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave);
|
||||
assert_eq!(h.state().engine.session(), &before);
|
||||
assert_eq!(h.state().engine.undo_history().len(), undo_len);
|
||||
assert!(
|
||||
std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio),
|
||||
"preview must retain cached media and peaks"
|
||||
);
|
||||
if screenshot {
|
||||
fade_shot(&mut h, &format!("waveform-{side}-{operation}-preview"));
|
||||
}
|
||||
h.drop_at(to);
|
||||
h.step();
|
||||
assert!(h.state().gesture.is_none());
|
||||
assert_eq!(waveform_geometry(&h, clip_rect), preview, "release must not cause a waveform jump");
|
||||
assert_eq!(waveform_geometry(&h, neighbour_rect), neighbour_wave);
|
||||
assert_eq!(h.state().engine.undo_history().len(), undo_len + 1);
|
||||
assert!(std::sync::Arc::ptr_eq(h.state().engine.session().pool.get(source).unwrap(), &audio));
|
||||
if screenshot {
|
||||
fade_shot(&mut h, &format!("waveform-{side}-{operation}-released"));
|
||||
}
|
||||
assert!(h.state_mut().engine.undo());
|
||||
assert_eq!(h.state().engine.session(), &before);
|
||||
h.step();
|
||||
assert_eq!(waveform_geometry(&h, clip_rect), original);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fade_drag_previews_and_commits_stay_inside_the_clip() {
|
||||
for fade_in in [true, false] {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# UI parity
|
||||
|
||||
> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (smooth fade curves and persistent midpoint controls) · **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
|
||||
|
|
@ -18,6 +18,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 | ✅ | 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 |
|
||||
|
|
@ -66,6 +67,7 @@ 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 |
|
||||
|
|
|
|||
Loading…
Reference in New Issue