Merge pull request #76 from grovebit/clip-gain

Change clip gain with Ctrl+Shift+Up/Down and a fader on the clip
This commit is contained in:
Brandon Thomas
2026-10-09 20:52:40 -04:00
committed by GitHub
7 changed files with 198 additions and 5 deletions
+22
View File
@@ -68,6 +68,8 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
Ok(json!({"clips": ids.len()}))
}
fn nudge_gain(e: &mut Engine, p: &Value, db: f64) -> Result<Value> {
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<Value> {
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);
}
}
+72 -4
View File
@@ -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<Rect> {
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 {
+1
View File
@@ -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 },
}
+1 -1
View File
@@ -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
+27
View File
@@ -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<f32> {
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}");
}
}
}
+53
View File
@@ -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<Event>) {
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);
}
+22
View File
@@ -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);
}