diff --git a/crates/engine/src/cmd/clip.rs b/crates/engine/src/cmd/clip.rs index 8b00ed19..0c566d70 100644 --- a/crates/engine/src/cmd/clip.rs +++ b/crates/engine/src/cmd/clip.rs @@ -68,6 +68,8 @@ pub fn specs() -> Vec { cmd!("clip.loop", "Loop...", ["Clip"], Some("Cmd+Alt+L"), "{clips?, count?: n, length?: samples}", has_selection, loop_clips), cmd!("clip.rename", "Rename...", ["Clip"], Some("Cmd+Alt+Shift+R"), "{clip?, name}", has_selection, rename), cmd!("clip.gain", "Clip Gain", [], None, "{clips?, db | delta_db}", has_selection, gain), + cmd!("clip.gain_nudge_up", "Nudge Clip Gain Up", [], Some("Ctrl+Shift+Up"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, 1.0)), + cmd!("clip.gain_nudge_down", "Nudge Clip Gain Down", [], Some("Ctrl+Shift+Down"), "{clips?}", has_selection, |e, p| nudge_gain(e, p, -1.0)), cmd!("clip.gain_render", "Render", ["Clip", "Clip Gain"], None, "{clips?}", has_selection, |e, p| { let ids = clip_ids_param(e, p); let mut n = 0; @@ -326,6 +328,11 @@ fn gain(e: &mut Engine, p: &Value) -> Result { Ok(json!({"clips": ids.len()})) } +fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result { + let clips = clip_ids_param(e, p); + gain(e, &json!({"clips": clips, "delta_db": db})) +} + fn quantize_to_grid(e: &mut Engine, p: &Value) -> Result { let ids = clip_ids_param(e, p); let s = e.session(); @@ -365,4 +372,19 @@ mod tests { e.execute("clip.remove_warp", &json!({"clips": [cid]})).unwrap(); assert_eq!(e.session().find_clip(ClipId(cid)).unwrap().1.length, len0); } + + #[test] + fn gain_nudges_one_db_at_a_time_up_to_the_ceiling() { + let mut e = crate::demo::demo_engine(); + let kick = e.session().track_by_name("Kick").unwrap().clips()[0].id; + let gain = |e: &crate::Engine| e.session().find_clip(kick).unwrap().1.gain_db; + e.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + e.execute("clip.gain_nudge_up", &json!({})).unwrap(); + e.execute("clip.gain_nudge_up", &json!({})).unwrap(); + e.execute("clip.gain_nudge_down", &json!({})).unwrap(); + assert_eq!(gain(&e), 1.0); + e.execute("clip.gain", &json!({"db": 35.5})).unwrap(); + e.execute("clip.gain_nudge_up", &json!({})).unwrap(); + assert_eq!(gain(&e), 36.0); + } } diff --git a/crates/ui-egui/src/edit_window.rs b/crates/ui-egui/src/edit_window.rs index f120c4a2..0ea03a22 100644 --- a/crates/ui-egui/src/edit_window.rs +++ b/crates/ui-egui/src/edit_window.rs @@ -1233,9 +1233,12 @@ fn draw_clip(app: &SoundApp, painter: &egui::Painter, s: &Session, track: &Track pts.push(pos2(x1, gy(clip.gain_db + clip.gain_env.last().map_or(0.0, |p| p.1)))); painter.add(Shape::line(pts, Stroke::new(1.0, col))); } - if s.edit.flag("view.clip.gain_info") || clip.gain_db.abs() > 0.01 { - painter.text(pos2(r.max.x - 4.0, body_r.max.y - 8.0), Align2::RIGHT_CENTER, format!("{:+.1} dB", clip.gain_db), regular(9.5), col); - } + } + if !dim && let Some(icon) = clip_gain_icon(s, tl, lane, clip) { + let col = Color32::from_rgba_unmultiplied(255, 255, 255, 200); + painter.line_segment([pos2(icon.center().x, icon.min.y), pos2(icon.center().x, icon.max.y)], Stroke::new(1.0, col)); + 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); } // Fades. let fade_col = Color32::from_rgba_unmultiplied(255, 255, 255, 90); @@ -1383,6 +1386,31 @@ fn clip_at(track: &Track, at: Samples) -> Option<&Clip> { track.clips().iter().rev().find(|c| c.range().contains(at)) } +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. +pub fn clip_gain_icon(s: &Session, tl: Rect, lane: Rect, clip: &Clip) -> Option { + let x0 = x_of(s, tl, clip.start).max(lane.min.x); + let x1 = x_of(s, tl, clip.end()).min(lane.max.x); + let shown = matches!(clip.content, ClipContent::Audio { .. }) && (s.edit.flag("view.clip.gain_info") || clip.gain_db.abs() > 0.01); + (shown && x1 - x0 >= 48.0 && lane.height() >= 30.0).then(|| Rect::from_min_size(pos2(x0 + 4.0, lane.max.y - 16.0), vec2(9.0, 13.0))) +} + +fn clip_gain_popup(ctx: &egui::Context, fader_bottom: Pos2, pos: f32) { + let t = Tokens::current(); + let p = ctx.layer_painter(egui::LayerId::new(egui::Order::Tooltip, egui::Id::new("clip_gain_fader"))); + let track = Rect::from_min_max(pos2(fader_bottom.x - 2.0, fader_bottom.y - CLIP_FADER_TRAVEL), pos2(fader_bottom.x + 2.0, fader_bottom.y)); + let panel = Rect::from_min_max(pos2(track.min.x - 22.0, track.min.y - 26.0), pos2(track.max.x + 22.0, track.max.y + 8.0)); + p.rect(panel, 3.0, Color32::from_rgb(38, 38, 40), Stroke::new(1.0, Color32::from_rgb(10, 10, 10)), StrokeKind::Inside); + p.rect_filled(track, 2.0, t.fader_track); + let zero = track.max.y - CLIP_FADER_TRAVEL * crate::widgets::clip_gain_to_pos(0.0); + p.line_segment([pos2(track.min.x - 8.0, zero), pos2(track.min.x - 2.0, zero)], Stroke::new(1.0, Color32::from_rgb(150, 150, 150))); + let cap = Rect::from_center_size(pos2(track.center().x, track.max.y - CLIP_FADER_TRAVEL * pos), vec2(22.0, 9.0)); + p.rect(cap, 2.0, Color32::from_rgb(210, 210, 210), Stroke::new(1.0, Color32::from_rgb(20, 20, 20)), StrokeKind::Inside); + let db = crate::widgets::clip_gain_from_pos(pos); + p.text(pos2(panel.center().x, panel.min.y + 12.0), Align2::CENTER_CENTER, format!("{db:+.1}"), regular(10.5), t.counter_text); +} + fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, tl: Rect) { let id = ui.id().with(("lane", track.id.0)); let resp = ui.interact(lane, id, Sense::click_and_drag()); @@ -1479,7 +1507,27 @@ 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 resp.drag_started() + 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()) + }; + if resp.hovered() && gain_icon_at(p).is_some() { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + } + let press = ui.input(|i| i.pointer.press_origin()).unwrap_or(p); + let gain_drag = if resp.drag_started() { gain_icon_at(press) } else { None }; + if let Some(c) = gain_drag { + // `clip.gain` refuses to run with nothing selected. + if s.edit.selected_tracks.is_empty() && s.edit.selected_clips.is_empty() { + let _ = app.engine.execute("edit.select", &json!({"clips": [c.id.0]})); + } + let pos = crate::widgets::clip_gain_to_pos(c.gain_db); + let screen = ui.ctx().content_rect(); + let y = (press.y + CLIP_FADER_TRAVEL * pos).max(screen.min.y + CLIP_FADER_TRAVEL + 30.0).min(screen.max.y - 10.0); + let fader_bottom = pos2(press.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: (press.y, pos), fine, fader_bottom }); + } else if resp.drag_started() && let (Some(fade_in), Some(c)) = (fade_corner, hit.as_ref()) { app.gesture = Some(Gesture::Fade { clip: c.id, track: track.id, fade_in, to: at }); @@ -1529,6 +1577,19 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, } Some(Gesture::TrimStart { to, .. } | Gesture::TrimEnd { to, .. }) => *to = snap(&s, at), Some(Gesture::Fade { to, .. }) => *to = at, + Some(Gesture::ClipGain { clip, pos, anchor, fine, .. }) => { + // The fader follows the pointer from where it was pressed; ⌘ re-anchors for fine moves. + if *fine != (mods.command || mods.ctrl) { + *fine = !*fine; + *anchor = (p.y, *pos); + } + let scale = if *fine { 0.1 } else { 1.0 }; + *pos = (anchor.1 + (anchor.0 - p.y) / CLIP_FADER_TRAVEL * scale).clamp(0.0, 1.0); + let db = crate::widgets::clip_gain_from_pos(*pos); + if s.find_clip(*clip).is_some_and(|(_, c)| (c.gain_db - db).abs() > 0.001) { + let _ = app.engine.execute_merged("clip.gain", &json!({"clips": [clip.0], "db": db}), &format!("clip_gain:{}", clip.0)); + } + } Some(Gesture::Scrub { last }) => { *last = at; let _ = app.engine.execute("transport.locate", &json!({"at": at})); @@ -1559,6 +1620,13 @@ fn lane_interaction(app: &mut SoundApp, ui: &mut Ui, track: &Track, lane: Rect, let _ = app.engine.execute("edit.select", &json!({"tracks": ids, "start": r.start, "end": r.end})); } } + if let Some(Gesture::ClipGain { track: t, pos, fader_bottom, .. }) = &app.gesture + && *t == track.id + && resp.dragged() + { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeVertical); + clip_gain_popup(ui.ctx(), *fader_bottom, *pos); + } if resp.drag_stopped() { let g = app.gesture.take(); match g { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 3967bad0..fc0685d2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -187,6 +187,7 @@ pub enum Gesture { Fade { clip: soundcraft_model::ClipId, track: TrackId, fade_in: bool, to: Samples }, 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 }, Pencil { track: TrackId, points: Vec<(Samples, f32)> }, ZoomBox { start: f32 }, } diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 856a45d1..bffa7130 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -165,7 +165,7 @@ fn fixed(app: &mut SoundApp, key: Key, m: Modifiers, mac: bool) -> bool { } true } - Key::ArrowUp | Key::ArrowDown if m.ctrl => { + Key::ArrowUp | Key::ArrowDown if m.ctrl && !m.shift => { let id = if key == Key::ArrowUp { "edit.extend_selection_up" } else { "edit.extend_selection_down" }; let _ = app.run(id, json!({})); true diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5ed45848..da913a8c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -172,6 +172,17 @@ pub fn meter_pos(db: f32) -> f32 { } } +/// Clip gain fader taper: the top three quarters span -36..+36 dB evenly, the rest compresses to -144. +pub fn clip_gain_from_pos(pos: f32) -> f32 { + let p = if pos.is_finite() { pos.clamp(0.0, 1.0) } else { 0.0 }; + if p >= 0.25 { -36.0 + (p - 0.25) / 0.75 * 72.0 } else { -36.0 - (1.0 - p / 0.25).powf(1.5) * 108.0 } +} + +pub fn clip_gain_to_pos(db: f32) -> f32 { + let db = if db.is_finite() { db.clamp(-144.0, 36.0) } else { -144.0 }; + if db >= -36.0 { 0.25 + (db + 36.0) / 72.0 * 0.75 } else { (1.0 - ((-36.0 - db) / 108.0).powf(1.0 / 1.5)) * 0.25 } +} + /// A fader: returns the new dB value when dragged. `db` in -144..12. pub fn fader(ui: &mut Ui, r: Rect, db: f32, id: egui::Id) -> Option { let t = Tokens::current(); @@ -344,3 +355,19 @@ pub fn multi_meter(ui: &Ui, r: Rect, levels: &[f32], labels: &[String], clip: bo } } } + +#[cfg(test)] +mod tests { + use super::{clip_gain_from_pos, clip_gain_to_pos}; + + #[test] + fn clip_gain_taper_spans_minus_144_to_plus_36_and_round_trips() { + assert_eq!(clip_gain_from_pos(0.0), -144.0); + assert_eq!(clip_gain_from_pos(1.0), 36.0); + assert_eq!(clip_gain_from_pos(f32::NAN), -144.0); + assert_eq!(clip_gain_to_pos(f32::INFINITY), 0.0); + for db in [-144.0, -90.0, -36.0, -12.5, 0.0, 6.0, 36.0] { + assert!((clip_gain_from_pos(clip_gain_to_pos(db)) - db).abs() < 0.01, "{db}"); + } + } +} diff --git a/crates/ui-egui/tests/clip_gain_fader.rs b/crates/ui-egui/tests/clip_gain_fader.rs new file mode 100644 index 00000000..2d456b3e --- /dev/null +++ b/crates/ui-egui/tests/clip_gain_fader.rs @@ -0,0 +1,53 @@ +use egui::{Event, PointerButton, Pos2, RawInput, Rect, pos2, vec2}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, edit_window, widgets}; + +fn frame(ctx: &egui::Context, app: &mut SoundApp, events: Vec) { + let raw = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1600.0, 1000.0))), events, ..Default::default() }; + ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }) + .textures_delta + .clear(); +} + +fn button(pos: Pos2, pressed: bool) -> Event { + Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Default::default() } +} + +#[test] +fn the_clip_gain_icon_drags_with_the_pointer_in_one_undo_step() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + app.engine.execute("view.clip_gain_info", &json!({"value": true})).unwrap(); + let ctx = egui::Context::default(); + frame(&ctx, &mut app, vec![]); + frame(&ctx, &mut app, vec![]); + let s = app.engine.session().clone(); + let kick = s.track_by_name("Kick").unwrap(); + let clip = kick.clips()[0].clone(); + let [x0, y0, x1, y1] = app.edit_layout.timeline; + let tl = Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)); + let row = app.edit_layout.rows.iter().find(|(id, _)| *id == kick.id.0).unwrap().1; + let lane = Rect::from_min_max(pos2(tl.min.x, row[1]), pos2(tl.max.x, row[1] + kick.height.points())); + let at = edit_window::clip_gain_icon(&s, tl, lane, &clip).unwrap().center(); + let gain = |app: &SoundApp| app.engine.session().find_clip(clip.id).unwrap().1.gain_db; + let up = |dy: f32| widgets::clip_gain_from_pos(widgets::clip_gain_to_pos(0.0) + dy / edit_window::CLIP_FADER_TRAVEL); + + frame(&ctx, &mut app, vec![Event::PointerMoved(at)]); + frame(&ctx, &mut app, vec![button(at, true)]); + for dy in [3.0, 6.0, 10.0, 40.0] { + frame(&ctx, &mut app, vec![Event::PointerMoved(at - vec2(0.0, dy))]); + } + assert!((gain(&app) - up(40.0)).abs() < 0.01, "{} vs {}", gain(&app), up(40.0)); + // Past the top end and back: the fader waits at +36 dB until the pointer returns. + for dy in [300.0, 120.0, 20.0] { + frame(&ctx, &mut app, vec![Event::PointerMoved(at - vec2(0.0, dy))]); + } + assert!((gain(&app) - up(20.0)).abs() < 0.01, "{} vs {}", gain(&app), up(20.0)); + frame(&ctx, &mut app, vec![button(at - vec2(0.0, 20.0), false)]); + frame(&ctx, &mut app, vec![]); + + app.engine.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(gain(&app), 0.0); +} diff --git a/crates/ui-egui/tests/shortcuts.rs b/crates/ui-egui/tests/shortcuts.rs new file mode 100644 index 00000000..0844e66a --- /dev/null +++ b/crates/ui-egui/tests/shortcuts.rs @@ -0,0 +1,22 @@ +use egui::{Key, Modifiers}; +use serde_json::json; +use soundcraft_ui_egui::{Services, SoundApp, shortcuts}; + +fn press(ctx: &egui::Context, app: &mut SoundApp, key: Key, modifiers: Modifiers) { + let events = [true, false].map(|pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers }).to_vec(); + ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| shortcuts::handle(app, ui.ctx())).textures_delta.clear(); +} + +#[test] +fn ctrl_shift_arrows_nudge_the_selected_clip_gain() { + let mut app = SoundApp::new(soundcraft_engine::demo::demo_engine(), None, Services::default()); + let kick = app.engine.session().track_by_name("Kick").unwrap().clips()[0].id; + app.engine.execute("edit.select", &json!({"clips": [kick.0]})).unwrap(); + let ctx = egui::Context::default(); + ctx.set_os(egui::os::OperatingSystem::Mac); + let ctrl_shift = Modifiers { ctrl: true, shift: true, ..Modifiers::NONE }; + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowUp, ctrl_shift); + press(&ctx, &mut app, Key::ArrowDown, ctrl_shift); + assert_eq!(app.engine.session().find_clip(kick).unwrap().1.gain_db, 1.0); +}