Draw faders and meters more like Pro Tools

Pro Tools draws a strip's fader and meter as one unit: the meter's dB
scale sits between them, the meter spans the fader's travel, and its
bar is a solid column over dark tints of its zones. Ours had LED
stripes, no meter scale, and a meter taller than the fader's travel.

- Meters: a solid bar that lights green, yellow and red over dark tints
  of those zones (fainter in light wells), ruled at the scale's marks,
  with a clip light above the bar and a 2 pt peak hold in its zone's
  colour. This is the shared meter, so the Edit window, toolbar and
  transport meters change too.
- Mix strips: the meter runs the fader's travel, 0 dBFS level with
  +12, and its 0-60 dB scale sits between the fader and the meter.
  Narrow strips show the fader's ticks without numbers, as Pro Tools'
  Narrow Mix does.
- Faders: a narrower, taller cap (18 x 40 pt, 16 pt wide in narrow
  strips) with a pinched waist and a dark centre line, in a 4 pt slot.
  The scale's numbers sit beside the cap and skip marks too close to
  the one above.

tests/mix_strip_layout.rs checks that the meter scale's 0 is level
with the fader's 12 and that narrow strips draw no scale numbers; both
fail before this change.

Part of #138.
This commit is contained in:
grovebit 2026-10-13 01:33:28 +01:00
parent 6e361262e1
commit c82b3887f0
3 changed files with 114 additions and 44 deletions

View File

@ -3,7 +3,7 @@
use crate::SoundApp;
use crate::theme::{Tokens, bold, regular, rgb};
use crate::widgets::{
PannerEdit, PannerSpeaker, db_text, fader, inset, key, meter, multi_meter, pan_knob, pan_text, rec_toggle, selector_box,
PannerEdit, PannerSpeaker, db_text, fader, fader_slot, inset, key, meter, meter_scale, multi_meter, pan_knob, pan_text, rec_toggle, selector_box,
selector_box_with_tooltip, shade, surround_panner, text_toggle, vgradient,
};
use egui::{Align2, Color32, CornerRadius, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2};
@ -350,7 +350,11 @@ fn strip(app: &mut SoundApp, ui: &mut Ui, id: TrackId, snap: Option<&MeterSnapsh
let _ = app.engine.execute_merged("mix.volume", &json!({"track": id.0, "db": db}), &format!("fader:{}", id.0));
}
let md = app.meters.get(&id).copied().unwrap_or_default();
let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, fr.min.y + 6.0), pos2(x0 + inner_w - 2.0, fr.max.y - 6.0));
let slot = fader_slot(fr);
let mr = Rect::from_min_max(pos2(fr.max.x + 4.0, slot.min.y - 5.0), pos2(x0 + inner_w - 2.0, slot.max.y + if wide_meter { 17.0 } else { 1.0 }));
if !narrow && !wide_meter {
meter_scale(ui.painter(), mr, mr.min.x - 1.0);
}
let strip_fmt = if track.kind == TrackKind::Master { app.engine.session().main_format() } else { track.format };
let all_chans = strip_fmt.channels().min(soundcraft_mix::MAX_CHANNELS);
let chans = if track.kind == TrackKind::Master { 2 } else { track.channels().min(2) };

View File

@ -200,41 +200,68 @@ pub fn pan_text(v: f32) -> String {
}
}
/// Vertical LED peak meter with green/yellow/red zones; values are linear peaks.
/// The dB marks of a meter's scale, also ruled across its bar.
const METER_MARKS: [f32; 8] = [0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0];
/// The bar of a [`meter`] drawn in `r`, below its clip light.
fn meter_bar(r: Rect) -> Rect {
Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 5.0), pos2(r.max.x - 1.0, r.max.y - 1.0))
}
/// The marks that fit on `bar`, at least 9 pt apart from the top down, with their heights.
fn meter_marks(bar: Rect) -> impl Iterator<Item = (f32, f32)> {
let mut last = f32::NEG_INFINITY;
METER_MARKS.into_iter().filter_map(move |db| {
let y = bar.max.y - bar.height() * meter_pos(db);
(y - last >= 9.0).then(|| {
last = y;
(db, y)
})
})
}
/// Vertical peak meter as Pro Tools draws it: a clip light above a bar that lights green, yellow
/// and red over dark tints of those zones, ruled at the scale's marks. Values are linear peaks.
pub fn meter(ui: &Ui, r: Rect, level: f32, hold: f32, clip: bool) {
let t = Tokens::current();
let p = ui.painter();
p.rect_filled(r, 0.0, t.meter_bg);
let pos = |v: f32| -> f32 {
let db = if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() };
meter_pos(db)
};
let h = r.height() - 4.0;
let inner = Rect::from_min_max(pos2(r.min.x + 1.0, r.min.y + 3.0), pos2(r.max.x - 1.0, r.max.y - 1.0));
let lv = pos(level);
p.rect_filled(Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 3.0)), 1.0, if clip { t.meter_red } else { t.meter_clip_off });
p.rect_filled(Rect::from_min_max(pos2(r.min.x, r.min.y + 4.0), r.max), 0.0, t.meter_bg);
let bar = meter_bar(r);
let y = |pos: f32| bar.max.y - bar.height() * pos;
let (amber, red) = (meter_pos(-12.0), meter_pos(-3.0));
let mut leds = Mesh::default();
for i in 0..(h / 3.0).clamp(0.0, 1000.0) as usize {
let y = inner.max.y - i as f32 * 3.0;
let f = (i as f32 * 3.0 + 1.0) / h;
let zone = if f > red {
t.meter_red
} else if f > amber {
t.meter_yellow
} else {
t.meter_green
};
let col = if f <= lv { zone } else { zone.gamma_multiply(0.1) };
quad(&mut leds, Rect::from_x_y_ranges(inner.x_range(), y - 2.0..=y), [col; 4]);
let zones = [(0.0, amber, t.meter_green), (amber, red, t.meter_yellow), (red, 1.0, t.meter_red)];
let lit = level_pos(level);
let unlit = if t.meter_bg.intensity() > 0.5 { 0.08 } else { 0.18 };
let mut mesh = Mesh::default();
for (lo, hi, color) in zones {
let top = lit.clamp(lo, hi);
quad(&mut mesh, Rect::from_x_y_ranges(bar.x_range(), y(top)..=y(lo)), [color; 4]);
quad(&mut mesh, Rect::from_x_y_ranges(bar.x_range(), y(hi)..=y(top)), [color.gamma_multiply(unlit); 4]);
}
p.add(leds);
let hv = pos(hold);
if hv > 0.01 {
let y = inner.max.y - h * hv;
p.line_segment([pos2(inner.min.x, y), pos2(inner.max.x, y)], Stroke::new(1.0, if hv > red { t.meter_red } else { t.meter_yellow }));
p.add(mesh);
for (_, at) in meter_marks(bar).skip(1) {
p.hline(bar.x_range(), p.round_to_pixel_center(at), Stroke::new(1.0, Color32::from_black_alpha(90)));
}
let clip_r = Rect::from_min_max(r.min, pos2(r.max.x, r.min.y + 2.5));
p.rect_filled(clip_r, 0.0, if clip { t.meter_red } else { t.meter_clip_off });
let held = level_pos(hold);
if held > 0.01 {
let color = zones.iter().find(|z| held <= z.1).map_or(t.meter_red, |z| z.2);
p.rect_filled(Rect::from_x_y_ranges(bar.x_range(), y(held) - 1.0..=y(held) + 1.0), 0.0, color);
}
}
/// The dB scale of a [`meter`] drawn in `r`, its numbers ending at `x`.
pub fn meter_scale(p: &Painter, r: Rect, x: f32) {
let t = Tokens::current();
for (db, y) in meter_marks(meter_bar(r)) {
p.text(pos2(x - 3.0, y), Align2::RIGHT_CENTER, format!("{}", db.abs()), regular(8.0), t.text_dim);
p.hline(x - 2.0..=x, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7)));
}
}
/// A linear peak → 0..1 meter position.
pub fn level_pos(v: f32) -> f32 {
meter_pos(if v <= 1e-6 { -80.0 } else { 20.0 * v.log10() })
}
/// dB → 0..1 meter position (logarithmic-ish scale like hardware meters).
@ -266,36 +293,48 @@ pub fn clip_gain_to_pos(db: f32) -> f32 {
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 }
}
const FADER_CAP_H: f32 = 40.0;
/// The travel of a [`fader`] drawn in `r`: its cap's centre runs from the bottom (-inf) to the
/// top (+12 dB).
pub fn fader_slot(r: Rect) -> Rect {
Rect::from_center_size(r.center(), vec2(4.0, r.height() - FADER_CAP_H))
}
/// 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();
let resp = ui.interact(r, id, Sense::click_and_drag());
let p = ui.painter();
let cap_size = vec2((r.width() * 0.44).clamp(16.0, 24.0), 34.0);
let slot = Rect::from_center_size(r.center(), vec2(5.0, r.height() - cap_size.y));
let slot = fader_slot(r);
let cap_w = (r.width() * 0.4).clamp(16.0, 18.0);
p.rect_filled(slot, 2.0, t.fader_track);
p.vline(slot.min.x + 0.5, slot.min.y + 1.0..=slot.max.y - 1.0, Stroke::new(1.0, Color32::from_black_alpha(120)));
p.vline(slot.max.x + 0.5, slot.min.y + 2.0..=slot.max.y - 2.0, Stroke::new(1.0, Color32::from_white_alpha(18)));
let numbers_x = slot.center().x - cap_w * 0.5 - 1.0;
let mut last = f32::NEG_INFINITY;
for mark in [12.0f32, 6.0, 0.0, -5.0, -10.0, -15.0, -20.0, -30.0, -40.0, -60.0] {
let y = slot.max.y - slot.height() * soundcraft_model::fader_db_to_pos(mark);
let x = p.text(pos2(r.min.x, y), Align2::LEFT_CENTER, format!("{}", mark.abs()), regular(8.0), t.text_dim).max.x + 2.0;
if x < slot.min.x - 3.0 {
p.hline(x..=slot.min.x - 3.0, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7)));
p.hline(slot.min.x - 6.0..=slot.min.x - 2.0, y, Stroke::new(1.0, t.text_dim.gamma_multiply(0.7)));
if numbers_x - r.min.x >= 10.0 && y - last >= 9.0 {
p.text(pos2(numbers_x, y), Align2::RIGHT_CENTER, format!("{}", mark.abs()), regular(8.0), t.text_dim);
last = y;
}
}
let pos = soundcraft_model::fader_db_to_pos(db);
let y = slot.max.y - slot.height() * pos;
let cap = Rect::from_center_size(pos2(slot.center().x, y), cap_size);
let cap = Rect::from_center_size(pos2(slot.center().x, y), vec2(cap_w, FADER_CAP_H));
p.rect_filled(cap.translate(vec2(0.0, 2.0)).expand(1.0), 4.0, Color32::from_black_alpha(80));
let g = Color32::from_gray;
vgradient(p, cap.shrink(1.0), g(226), g(146));
p.rect_stroke(cap, 3.0, Stroke::new(1.0, g(34)), StrokeKind::Inside);
for dy in [-10.0, -7.0, 7.0, 10.0] {
let x = cap.min.x + 3.0..=cap.max.x - 3.0;
p.hline(x.clone(), cap.center().y + dy, Stroke::new(1.0, Color32::from_black_alpha(50)));
p.hline(x, cap.center().y + dy + 1.0, Stroke::new(1.0, Color32::from_white_alpha(90)));
p.rect_filled(cap, 3.0, g(34));
let face = cap.shrink(1.0);
rounded_vgradient(p, face, 2.0, g(236), g(104));
vgradient(p, Rect::from_x_y_ranges(face.x_range(), y + 1.0..=face.max.y - 2.0), g(214), g(150));
p.hline(face.min.x + 2.0..=face.max.x - 2.0, face.min.y + 1.5, Stroke::new(1.0, Color32::from_white_alpha(110)));
p.hline(face.x_range(), y, Stroke::new(2.0, g(30)));
for (x, dx) in [(cap.min.x, 2.5), (cap.max.x, -2.5)] {
p.add(egui::Shape::convex_polygon(vec![pos2(x, y - 3.0), pos2(x + dx, y), pos2(x, y + 3.0)], g(34), Stroke::NONE));
}
p.hline(cap.min.x + 1.0..=cap.max.x - 1.0, cap.center().y, Stroke::new(2.0, g(24)));
if resp.double_clicked() || (resp.clicked() && ui.input(|i| i.modifiers.alt)) {
return Some(0.0);
}

View File

@ -85,3 +85,30 @@ fn stereo_strips_line_up_with_mono_strips() {
let (top, bottom) = solo_keys.iter().fold((f32::MAX, f32::MIN), |(lo, hi), &y| (lo.min(y), hi.max(y)));
assert!(bottom - top < 0.5, "solo keys span {top}..{bottom}");
}
#[test]
fn the_meter_starts_level_with_the_top_of_the_faders_travel() {
let _palette = PALETTE.lock().unwrap_or_else(|e| e.into_inner());
let harness = mix(ThemeMode::Dark);
let numbers: Vec<_> = texts(harness.output(), |t| t.galley.job.sections[0].format.font_id.size == 8.0)
.map(|t| (t.galley.job.text.as_str(), t.galley.rect.translate(t.pos.to_vec2())))
.collect();
let fader_tops: Vec<_> = numbers.iter().filter(|(text, _)| *text == "12").map(|(_, r)| *r).collect();
assert!(fader_tops.len() >= 6, "found {} fader scales", fader_tops.len());
for top in fader_tops {
let meter_top = numbers.iter().find(|(text, r)| *text == "0" && r.min.x > top.max.x + 16.0 && r.min.x < top.max.x + 60.0);
assert!(meter_top.is_some_and(|(_, r)| (r.center().y - top.center().y).abs() < 0.5), "meter scale beside {top:?}: {meter_top:?}");
}
}
#[test]
fn narrow_strips_show_ticks_without_numbers() {
let _palette = PALETTE.lock().unwrap_or_else(|e| e.into_inner());
let mut harness = mix(ThemeMode::Dark);
harness.state_mut().ui.narrow_mix = true;
for _ in 0..3 {
harness.step();
}
let numbers = texts(harness.output(), |t| t.galley.job.sections[0].format.font_id.size == 8.0 && SCALE.contains(&t.galley.job.text.as_str()));
assert_eq!(numbers.count(), 0);
}