diff --git a/crates/model/src/clip.rs b/crates/model/src/clip.rs index 054cb66..de3fc11 100644 --- a/crates/model/src/clip.rs +++ b/crates/model/src/clip.rs @@ -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); diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index 8fd5f65..4df4b8c 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -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, clip_rect: Rect) -> Vec> { + 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] { diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 2effb36..4d6bb09 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-13 · **Last updated:** 2026-10-13 · **Change:** minor (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 |