1
0
Fork 0

Add an iced peak meter widget

This commit is contained in:
Robbert van der Helm 2022-03-15 17:06:47 +01:00
parent d830a0a1e4
commit 4d58df1e08
4 changed files with 331 additions and 27 deletions

View file

@ -8,9 +8,11 @@
use nih_plug::param::internals::ParamPtr;
pub mod param_slider;
pub mod peak_meter;
pub mod util;
pub use param_slider::ParamSlider;
pub use peak_meter::PeakMeter;
/// A message to update a parameter value. Since NIH-plug manages the parameters, interacting with
/// parameter values with iced works a little different from updating any other state. This main

View file

@ -0,0 +1,300 @@
//! A super simple peak meter widget.
use crossbeam::atomic::AtomicCell;
use std::marker::PhantomData;
use std::time::Duration;
use std::time::Instant;
use crate::backend::Renderer;
use crate::renderer::Renderer as GraphicsRenderer;
use crate::text::Renderer as TextRenderer;
use crate::{
alignment, layout, renderer, text, Background, Color, Element, Font, Layout, Length, Point,
Rectangle, Size, Widget,
};
/// The thickness of this widget's borders.
const BORDER_WIDTH: f32 = 1.0;
/// The thickness of a tick inside of the peak meter's bar.
const TICK_WIDTH: f32 = 1.0;
/// A simple horizontal peak meter.
///
/// TODO: There are currently no styling options at all
/// TODO: Vertical peak meter, this is just a proof of concept to fit the gain GUI example.
pub struct PeakMeter<'a, Message> {
state: &'a mut State,
/// The current measured value in decibel.
current_value_db: f32,
/// The time the old peak value should remain visible.
hold_time: Option<Duration>,
height: Length,
width: Length,
text_size: Option<u16>,
font: Font,
/// We don't emit any messages, but iced requires us to define some message type anyways.
_phantom: PhantomData<Message>,
}
/// State for a [`PeakMeter`].
#[derive(Debug, Default)]
pub struct State {
/// The last peak value in decibel.
held_peak_value_db: AtomicCell<f32>,
/// When the last peak value was hit.
last_held_peak_value: AtomicCell<Option<Instant>>,
}
impl<'a, Message> PeakMeter<'a, Message> {
/// Creates a new [`PeakMeter`] using the current measurement in decibel. This measurement can
/// already have some form of smoothing applied to it. This peak slider widget can draw the last
/// hold value for you.
pub fn new(state: &'a mut State, value_db: f32) -> Self {
Self {
state,
current_value_db: value_db,
hold_time: None,
width: Length::Units(180),
height: Length::Units(30),
text_size: None,
font: <Renderer as TextRenderer>::Font::default(),
_phantom: PhantomData,
}
}
/// Keep showing the peak value for a certain amount of time.
pub fn hold_time(mut self, time: Duration) -> Self {
self.hold_time = Some(time);
self
}
/// Sets the width of the [`PeakMeter`].
pub fn width(mut self, width: Length) -> Self {
self.width = width;
self
}
/// Sets the height of the [`PeakMeter`].
pub fn height(mut self, height: Length) -> Self {
self.height = height;
self
}
/// Sets the text size of the [`PeakMeter`]'s ticks bar.
pub fn text_size(mut self, size: u16) -> Self {
self.text_size = Some(size);
self
}
/// Sets the font of the [`PeakMeter`]'s ticks bar.
pub fn font(mut self, font: Font) -> Self {
self.font = font;
self
}
}
impl<'a, Message> Widget<Message, Renderer> for PeakMeter<'a, Message>
where
Message: Clone,
{
fn width(&self) -> Length {
self.width
}
fn height(&self) -> Length {
self.height
}
fn layout(&self, _renderer: &Renderer, limits: &layout::Limits) -> layout::Node {
let limits = limits.width(self.width).height(self.height);
let size = limits.resolve(Size::ZERO);
layout::Node::new(size)
}
fn draw(
&self,
renderer: &mut Renderer,
style: &renderer::Style,
layout: Layout<'_>,
_cursor_position: Point,
_viewport: &Rectangle,
) {
let bounds = layout.bounds();
let bar_bounds = Rectangle {
height: bounds.height / 2.0,
..bounds
};
let ticks_bounds = Rectangle {
y: bounds.y + (bounds.height / 2.0),
height: bounds.height / 2.0,
..bounds
};
let text_size = self
.text_size
.unwrap_or_else(|| (renderer.default_size() as f32 * 0.7).round() as u16);
// We'll draw a simple horizontal for [-200, 20] dB where we'll treat -80 as -infinity, with
// a label containing the tick markers below it. If `.hold_time()` was called then we'll
// also display the last held value
const MIN_TICK: f32 = -100.0;
const MAX_TICK: f32 = 20.0;
let text_ticks = [-80i32, -60, -40, -20, 0];
// Draw a tick with one pixel in between, otherwise the bilinear interpolation makes
// everything a smeary mess
let bar_ticks_start = (bar_bounds.x + BORDER_WIDTH).round() as i32;
let bar_ticks_end = (bar_bounds.x + bar_bounds.width - (BORDER_WIDTH * 2.0)).ceil() as i32;
let bar_tick_coordinates =
(bar_ticks_start..bar_ticks_end).step_by((TICK_WIDTH + 1.0).round() as usize);
let db_to_x_coord = |db: f32| {
let tick_fraction = (db - MIN_TICK) / (MAX_TICK - MIN_TICK);
bar_ticks_start as f32
+ ((bar_ticks_end - bar_ticks_start) as f32 * tick_fraction).round()
};
for tick_x in bar_tick_coordinates {
let tick_fraction =
(tick_x - bar_ticks_start) as f32 / (bar_ticks_end - bar_ticks_start) as f32;
let tick_db = (tick_fraction * (MAX_TICK - MIN_TICK)) + MIN_TICK;
if tick_db > self.current_value_db {
break;
}
let tick_bounds = Rectangle {
x: tick_x as f32,
y: bar_bounds.y + BORDER_WIDTH,
width: TICK_WIDTH,
height: bar_bounds.height - (BORDER_WIDTH * 2.0),
};
let grayscale_color = 0.3 + ((1.0 - tick_fraction) * 0.5);
let tick_color = Color::from_rgb(grayscale_color, grayscale_color, grayscale_color);
renderer.fill_quad(
renderer::Quad {
bounds: tick_bounds,
border_radius: 0.0,
border_width: 0.0,
border_color: Color::TRANSPARENT,
},
Background::Color(tick_color),
);
}
// Draw the hold peak value if the hold time option has been set
if let Some(hold_time) = self.hold_time {
let now = Instant::now();
let mut held_peak_value_db = self.state.held_peak_value_db.load();
let last_peak_value = self.state.last_held_peak_value.load();
if self.current_value_db >= held_peak_value_db
|| last_peak_value.is_none()
|| now > last_peak_value.unwrap() + hold_time
{
self.state.held_peak_value_db.store(self.current_value_db);
self.state.last_held_peak_value.store(Some(now));
held_peak_value_db = self.current_value_db;
}
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: db_to_x_coord(held_peak_value_db),
y: bar_bounds.y + BORDER_WIDTH,
width: TICK_WIDTH,
height: bar_bounds.height - (BORDER_WIDTH * 2.0),
},
border_radius: 0.0,
border_width: 0.0,
border_color: Color::TRANSPARENT,
},
Background::Color(Color::from_rgb(0.3, 0.3, 0.3)),
);
}
// Draw the bar after the ticks since the first and last tick may overlap with the borders
renderer.fill_quad(
renderer::Quad {
bounds: bar_bounds,
border_radius: 0.0,
border_width: BORDER_WIDTH,
border_color: Color::BLACK,
},
Background::Color(Color::TRANSPARENT),
);
// Beneat the bar we want to draw the names of the ticks
for tick_db in text_ticks {
let x_coordinate = db_to_x_coord(tick_db as f32);
renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: x_coordinate,
y: ticks_bounds.y,
width: TICK_WIDTH,
height: ticks_bounds.height * 0.3,
},
border_radius: 0.0,
border_width: 0.0,
border_color: Color::TRANSPARENT,
},
Background::Color(Color::from_rgb(0.3, 0.3, 0.3)),
);
let tick_text = if tick_db == text_ticks[0] {
String::from("-inf")
} else {
tick_db.to_string()
};
renderer.fill_text(text::Text {
content: &tick_text,
font: self.font,
size: text_size as f32,
bounds: Rectangle {
x: x_coordinate,
y: ticks_bounds.y + (ticks_bounds.height * 0.35),
..ticks_bounds
},
color: style.text_color,
horizontal_alignment: alignment::Horizontal::Center,
vertical_alignment: alignment::Vertical::Top,
});
}
// Every proper graph needs a unit label
let zero_db_x_coordinate = db_to_x_coord(0.0);
let zero_db_text_width = renderer.measure_width("0", text_size, self.font);
renderer.fill_text(text::Text {
// The spacing looks a bit off if we start with a space here so we'll add a little
// offset to the x-coordinate instead
content: "dBFS",
font: self.font,
size: text_size as f32,
bounds: Rectangle {
x: zero_db_x_coordinate + (zero_db_text_width / 2.0) + (text_size as f32 * 0.2),
y: ticks_bounds.y + (ticks_bounds.height * 0.35),
..ticks_bounds
},
color: style.text_color,
horizontal_alignment: alignment::Horizontal::Left,
vertical_alignment: alignment::Vertical::Top,
});
}
}
impl<'a, Message: 'a> From<PeakMeter<'a, Message>> for Element<'a, Message>
where
Message: Clone,
{
fn from(widget: PeakMeter<'a, Message>) -> Self {
Element::new(widget)
}
}

View file

@ -1,8 +1,10 @@
use nih_plug::prelude::{Editor, GuiContext};
use atomic_float::AtomicF32;
use nih_plug::prelude::{util, Editor, GuiContext};
use nih_plug_iced::widgets as nih_widgets;
use nih_plug_iced::*;
use std::pin::Pin;
use std::sync::Arc;
use std::time::Duration;
use crate::GainParams;
@ -13,17 +15,20 @@ pub(crate) fn default_state() -> Arc<IcedState> {
pub(crate) fn create(
params: Pin<Arc<GainParams>>,
peak_meter: Arc<AtomicF32>,
editor_state: Arc<IcedState>,
) -> Option<Box<dyn Editor>> {
create_iced_editor::<GainEditor>(editor_state, params)
create_iced_editor::<GainEditor>(editor_state, (params, peak_meter))
}
struct GainEditor {
params: Pin<Arc<GainParams>>,
context: Arc<dyn GuiContext>,
peak_meter: Arc<AtomicF32>,
gain_slider_state: nih_widgets::param_slider::State,
meter_dummy_state: widget::button::State,
peak_meter_state: nih_widgets::peak_meter::State,
}
#[derive(Debug, Clone, Copy)]
@ -35,18 +40,20 @@ enum Message {
impl IcedEditor for GainEditor {
type Executor = executor::Default;
type Message = Message;
type InitializationFlags = Pin<Arc<GainParams>>;
type InitializationFlags = (Pin<Arc<GainParams>>, Arc<AtomicF32>);
fn new(
params: Self::InitializationFlags,
(params, peak_meter): Self::InitializationFlags,
context: Arc<dyn GuiContext>,
) -> (Self, Command<Self::Message>) {
let editor = GainEditor {
params,
context,
peak_meter,
gain_slider_state: Default::default(),
meter_dummy_state: widget::button::State::new(),
peak_meter_state: Default::default(),
};
(editor, Command::none())
@ -88,24 +95,20 @@ impl IcedEditor for GainEditor {
.vertical_alignment(alignment::Vertical::Center),
)
.push(
nih_widgets::ParamSlider::new(&mut self.gain_slider_state, &self.params.gain, self.context.as_ref()).map(Message::ParamUpdate)
// Button::new(&mut self.gain_dummy_state, Text::new("Gain"))
// .height(30.into())
// .width(180.into()),
nih_widgets::ParamSlider::new(
&mut self.gain_slider_state,
&self.params.gain,
self.context.as_ref(),
)
.map(Message::ParamUpdate),
)
.push(Space::with_height(10.into()))
.push(
Button::new(&mut self.meter_dummy_state, Text::new("Meter"))
.height(15.into())
.width(180.into()),
)
.push(
Text::new("Ticks 'n stuff")
.size(12)
.height(15.into())
.width(Length::Fill)
.horizontal_alignment(alignment::Horizontal::Center)
.vertical_alignment(alignment::Vertical::Center),
nih_widgets::PeakMeter::new(
&mut self.peak_meter_state,
util::gain_to_db(self.peak_meter.load(std::sync::atomic::Ordering::Relaxed)),
)
.hold_time(Duration::from_millis(600)),
)
.into()
}

View file

@ -25,10 +25,6 @@ struct Gain {
struct GainParams {
#[id = "gain"]
pub gain: FloatParam,
// TODO: Remove this parameter when we're done implementing the widgets
#[id = "foobar"]
pub some_int: IntParam,
}
impl Default for Gain {
@ -57,7 +53,6 @@ impl Default for GainParams {
.with_smoother(SmoothingStyle::Linear(50.0))
.with_step_size(0.01)
.with_unit(" dB"),
some_int: IntParam::new("Something", 3, IntRange::Linear { min: 0, max: 3 }),
}
}
}
@ -81,7 +76,11 @@ impl Plugin for Gain {
}
fn editor(&self) -> Option<Box<dyn Editor>> {
editor::create(self.params.clone(), self.editor_state.clone())
editor::create(
self.params.clone(),
self.peak_meter.clone(),
self.editor_state.clone(),
)
}
fn accepts_bus_config(&self, config: &BusConfig) -> bool {