mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 10:36:46 -06:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user