diff --git a/Cargo.lock b/Cargo.lock index e58ade5..a95f125 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1704,6 +1704,15 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "histogram" +version = "0.1.0" +dependencies = [ + "cyma", + "nih_plug", + "nih_plug_vizia", +] + [[package]] name = "iana-time-zone" version = "0.1.60" diff --git a/Cargo.toml b/Cargo.toml index 2c01233..951e607 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ description = "Composable views and associated data structures for nih-plug UIs resolver = "2" members = [ "xtask", - "examples/visualizers", "examples/peak_graph", + "examples/visualizers", "examples/peak_graph", "examples/histogram", ] [lib] diff --git a/examples/histogram/Cargo.toml b/examples/histogram/Cargo.toml new file mode 100644 index 0000000..e1c462e --- /dev/null +++ b/examples/histogram/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "histogram" +version = "0.1.0" +edition = "2021" +description = "A histogram built using Cyma" + +[lib] +crate-type = ["cdylib", "lib"] + +[dependencies] +nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", features = ["assert_process_allocs", "standalone"] } +cyma = { path = "../../" } +nih_plug_vizia = { git = "https://github.com/robbert-vdh/nih-plug.git" } + +[profile.release] +lto = "thin" +strip = "symbols" + +[profile.profiling] +inherits = "release" +debug = true +strip = "none" diff --git a/examples/histogram/README.md b/examples/histogram/README.md new file mode 100644 index 0000000..c73e3a5 --- /dev/null +++ b/examples/histogram/README.md @@ -0,0 +1,15 @@ +# Peak Graph + +> [!NOTE] +> This code is taken from the *Composing a Peak Graph* chapter of the Cyma Book. + +![A peak graph visualizer with a grid backdrop and a unit ruler to the side.](doc/peak_graph.png) + +A peak analyzer plugin. + +This example plug-in uses a peak buffer that stores incoming audio as peaks that +decay in amplitude. The buffer is behind an `Arc`. By cloning the Arc, a +reference is sent to the editor. The editor uses the buffer to draw a `PeakGraph`. + +Behind it is a grid, and to the side of it is a unit ruler. These views are composed +using VIZIA's *Stack* views. \ No newline at end of file diff --git a/examples/histogram/doc/peak_graph.png b/examples/histogram/doc/peak_graph.png new file mode 100644 index 0000000..0415a82 Binary files /dev/null and b/examples/histogram/doc/peak_graph.png differ diff --git a/examples/histogram/src/editor.rs b/examples/histogram/src/editor.rs new file mode 100644 index 0000000..fd01ea4 --- /dev/null +++ b/examples/histogram/src/editor.rs @@ -0,0 +1,71 @@ +use cyma::prelude::*; +use cyma::{ + utils::HistogramBuffer, + visualizers::{Grid, Histogram, UnitRuler}, +}; +use nih_plug::editor::Editor; +use nih_plug_vizia::{assets, create_vizia_editor, vizia::prelude::*, ViziaState, ViziaTheming}; +use std::sync::{Arc, Mutex}; + +#[derive(Lens, Clone)] +pub(crate) struct Data { + histogram_buffer: Arc>, +} + +impl Data { + pub(crate) fn new(histogram_buffer: Arc>) -> Self { + Self { histogram_buffer } + } +} + +impl Model for Data {} + +pub(crate) fn default_state() -> Arc { + ViziaState::new(|| (800, 500)) +} + +pub(crate) fn create(editor_data: Data, editor_state: Arc) -> Option> { + create_vizia_editor(editor_state, ViziaTheming::default(), move |cx, _| { + assets::register_noto_sans_light(cx); + editor_data.clone().build(cx); + + HStack::new(cx, |cx| { + ZStack::new(cx, |cx| { + Grid::new( + cx, + ValueScaling::Linear, + (-32., 8.), + vec![6.0, 0.0, -6.0, -12.0, -18.0, -24.0, -30.0], + Orientation::Horizontal, + ) + .color(Color::rgb(60, 60, 60)); + + Histogram::new(cx, Data::histogram_buffer, (-32.0, 8.0)) + .color(Color::rgba(255, 255, 255, 160)) + .background_color(Color::rgba(255, 255, 255, 60)); + }) + .background_color(Color::rgb(16, 16, 16)); + + UnitRuler::new( + cx, + (-32.0, 8.0), + ValueScaling::Linear, + vec![ + (6.0, "6db"), + (0.0, "0db"), + (-6.0, "-6db"), + (-12.0, "-12db"), + (-18.0, "-18db"), + (-24.0, "-24db"), + (-30.0, "-30db"), + ], + Orientation::Vertical, + ) + .font_size(12.) + .color(Color::rgb(160, 160, 160)) + .width(Pixels(48.)); + }) + .col_between(Pixels(8.)) + .background_color(Color::rgb(0, 0, 0)); + }) +} diff --git a/examples/histogram/src/lib.rs b/examples/histogram/src/lib.rs new file mode 100644 index 0000000..8325b3b --- /dev/null +++ b/examples/histogram/src/lib.rs @@ -0,0 +1,123 @@ +use cyma::prelude::*; +use cyma::utils::HistogramBuffer; +use nih_plug::prelude::*; +use nih_plug_vizia::ViziaState; +use std::sync::{Arc, Mutex}; + +mod editor; + +pub struct HistogramPlugin { + params: Arc, + histogram_buffer: Arc>, +} + +#[derive(Params)] +struct DemoParams { + #[persist = "editor-state"] + editor_state: Arc, +} + +impl Default for HistogramPlugin { + fn default() -> Self { + Self { + params: Arc::new(DemoParams::default()), + histogram_buffer: Arc::new(Mutex::new(HistogramBuffer::new(256, 1.0))), + } + } +} + +impl Default for DemoParams { + fn default() -> Self { + Self { + editor_state: editor::default_state(), + } + } +} + +impl Plugin for HistogramPlugin { + const NAME: &'static str = "CymaHistogram"; + const VENDOR: &'static str = "223230"; + const URL: &'static str = env!("CARGO_PKG_HOMEPAGE"); + const EMAIL: &'static str = "223230@pm.me"; + const VERSION: &'static str = env!("CARGO_PKG_VERSION"); + + const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[AudioIOLayout { + main_input_channels: NonZeroU32::new(2), + main_output_channels: NonZeroU32::new(2), + + aux_input_ports: &[], + aux_output_ports: &[], + + names: PortNames::const_default(), + }]; + + const MIDI_INPUT: MidiConfig = MidiConfig::None; + const MIDI_OUTPUT: MidiConfig = MidiConfig::None; + + const SAMPLE_ACCURATE_AUTOMATION: bool = true; + + type SysExMessage = (); + type BackgroundTask = (); + + fn params(&self) -> Arc { + self.params.clone() + } + + fn editor(&mut self, _async_executor: AsyncExecutor) -> Option> { + editor::create( + editor::Data::new(self.histogram_buffer.clone()), + self.params.editor_state.clone(), + ) + } + + fn initialize( + &mut self, + _audio_io_layout: &AudioIOLayout, + buffer_config: &BufferConfig, + _context: &mut impl InitContext, + ) -> bool { + match self.histogram_buffer.lock() { + Ok(mut buffer) => { + buffer.set_sample_rate(buffer_config.sample_rate); + } + Err(_) => return false, + } + + true + } + + fn process( + &mut self, + buffer: &mut nih_plug::buffer::Buffer, + _: &mut AuxiliaryBuffers, + _: &mut impl ProcessContext, + ) -> ProcessStatus { + // Append to the visualizers' respective buffers, only if the editor is currently open. + if self.params.editor_state.is_open() { + self.histogram_buffer + .lock() + .unwrap() + .enqueue_buffer(buffer, None); + } + ProcessStatus::Normal + } +} + +impl ClapPlugin for HistogramPlugin { + const CLAP_ID: &'static str = "org.cyma.histogram"; + const CLAP_DESCRIPTION: Option<&'static str> = Some("A histogram built using Cyma"); + const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL); + const CLAP_SUPPORT_URL: Option<&'static str> = None; + + const CLAP_FEATURES: &'static [ClapFeature] = + &[ClapFeature::AudioEffect, ClapFeature::Analyzer]; +} + +impl Vst3Plugin for HistogramPlugin { + const VST3_CLASS_ID: [u8; 16] = *b"CYMA000HISTOGRAM"; + + const VST3_SUBCATEGORIES: &'static [Vst3SubCategory] = &[Vst3SubCategory::Analyzer]; +} + +nih_export_clap!(HistogramPlugin); +nih_export_vst3!(HistogramPlugin); diff --git a/examples/histogram/src/main.rs b/examples/histogram/src/main.rs new file mode 100644 index 0000000..53f0b66 --- /dev/null +++ b/examples/histogram/src/main.rs @@ -0,0 +1,6 @@ +use histogram::HistogramPlugin; +use nih_plug::prelude::*; + +fn main() { + nih_export_standalone::(); +} diff --git a/src/utils/buffers/histogram_buffer.rs b/src/utils/buffers/histogram_buffer.rs new file mode 100644 index 0000000..86265e3 --- /dev/null +++ b/src/utils/buffers/histogram_buffer.rs @@ -0,0 +1,225 @@ +use super::VisualizerBuffer; +use std::fmt::Debug; +use std::ops::{Index, IndexMut}; + +/// This buffer creates histogram data with variable decay from a signal. +/// +/// After an element is added, all elements are scaled so the largest element has value 1 +/// Due to its fixed-size nature, the histogram buffer is very fast and doesn't dynamically reallocate itself. +// #[derive(Clone, PartialEq, Eq, Default, Hash, Debug)] +#[derive(Clone, PartialEq, Default, Debug)] +pub struct HistogramBuffer { + size: usize, + data: Vec, + sample_rate: f32, + // The decay time. + decay: f32, + // when a sample is added to a bin, add this number to that bin + // then scale the whole vector so the max is 1 + // together these make older values decay; the smaller decay_weight, the faster the decay + decay_weight: f32, + edges: Vec, +} +const MIN_EDGE: f32 = -96.0; +const MAX_EDGE: f32 = 24.0; + +impl HistogramBuffer { + /// Constructs a new HistogramBuffer with the given size. + /// + /// * `size` - The number of bins; Usually, this can be kept < 2000 + /// * `decay` - The rate of decay + /// + /// The buffer needs to be provided a sample rate after initialization - do this by + /// calling [`set_sample_rate`](Self::set_sample_rate) inside your + /// [`initialize()`](nih_plug::plugin::Plugin::initialize) function. + pub fn new(size: usize, decay: f32) -> Self { + let decay_weight = Self::decay_weight(decay, 48000.); + Self { + size, + data: vec![f32::default(); size], + sample_rate: 48000., + decay, + decay_weight, + edges: vec![f32::default(); size - 1], + } + } + + /// Sets the decay time of the `HistogramBuffer`. + /// + /// * `decay` - The time it takes for a sample inside the buffer to decrease by -12dB, in milliseconds + pub fn set_decay(self: &mut Self, decay: f32) { + self.decay = decay; + self.update(); + } + + /// Sets the sample rate of the incoming audio. + /// + /// This function **clears** the buffer. You can call it inside your + /// [`initialize()`](nih_plug::plugin::Plugin::initialize) function and provide the + /// sample rate like so: + /// + /// ``` + /// fn initialize( + /// &mut self, + /// _audio_io_layout: &AudioIOLayout, + /// buffer_config: &BufferConfig, + /// _context: &mut impl InitContext, + /// ) -> bool { + /// match self.histogram_buffer.lock() { + /// Ok(mut buffer) => { + /// buffer.set_sample_rate(buffer_config.sample_rate); + /// } + /// Err(_) => return false, + /// } + /// + /// true + /// } + /// ``` + pub fn set_sample_rate(self: &mut Self, sample_rate: f32) { + self.sample_rate = sample_rate; + self.update(); + self.clear(); + } + + fn decay_weight(decay: f32, sample_rate: f32) -> f32 { + 0.25f64.powf((decay as f64 * sample_rate as f64).recip()) as f32 + } + + fn update(self: &mut Self) { + // calculate the linear edge values from MIN_EDGE to MAX_EDGE, evenly spaced in the db domain + let nr_edges: usize = self.size - 1; + self.edges = (0..nr_edges) + .map(|x| { + Self::db_to_linear( + MIN_EDGE + x as f32 * ((MAX_EDGE - MIN_EDGE) / (nr_edges as f32 - 1.0)), + ) + }) + .collect::>() + .try_into() + .unwrap(); + + self.decay_weight = Self::decay_weight(self.decay, self.sample_rate); + } + + fn db_to_linear(db: f32) -> f32 { + 10.0_f32.powf(db / 20.0) + } + + // Function to find the bin for a given linear audio value + fn find_bin(&self, value: f32) -> usize { + // Check if the value is smaller than the first edge + if value < self.edges[0] { + // if value < f32::EPSILON { + // if value == 0.0 { + return 0; + } + // Check if the value is larger than the last edge + if value > *self.edges.last().unwrap() { + return self.edges.len() as usize; + } + // Binary search to find the bin for the given value + let mut left = 0; + let mut right = self.edges.len() - 1; + + while left <= right { + let mid = left + (right - left) / 2; + if value >= self.edges[mid] { + left = mid + 1; + } else { + right = mid - 1; + } + } + // Return the bin index + left as usize + } +} + +impl VisualizerBuffer for HistogramBuffer { + fn enqueue(self: &mut Self, value: f32) { + let value = value.abs(); + // don't enqueue silence + if value > 0.0 { + let bin_index = self.find_bin(value); + for i in 0..self.size - 1 { + // decay all values + self.data[i] *= self.decay_weight; + } + self.data[bin_index] += (1.0 - self.decay_weight); // Increment the count for the bin + } + } + + fn enqueue_buffer( + self: &mut Self, + buffer: &mut nih_plug::buffer::Buffer, + channel: Option, + ) { + match channel { + Some(channel) => { + for sample in buffer.as_slice()[channel].into_iter() { + self.enqueue(*sample); + } + } + None => { + for sample in buffer.iter_samples() { + self.enqueue( + (1. / (&sample).len() as f32) * sample.into_iter().map(|x| *x).sum::(), + ); + } + } + } + } + + /// Resizes the buffer to the given size, **clearing it**. + fn resize(self: &mut Self, size: usize) { + if size == self.len() { + return; + } + self.clear(); + self.size = size; + self.update(); + } + + /// Clears the entire buffer, filling it with default values (usually 0) + fn clear(self: &mut Self) { + self.data.iter_mut().for_each(|x| *x = f32::default()); + } + + fn len(self: &Self) -> usize { + self.size + } + + /// Grows the buffer, **clearing it**. + fn grow(self: &mut Self, size: usize) { + self.resize(size); + } + + /// Shrinks the buffer, **clearing it**. + fn shrink(self: &mut Self, size: usize) { + self.resize(size) + } +} + +impl Index for HistogramBuffer { + type Output = f32; + + fn index(&self, index: usize) -> &Self::Output { + if index >= self.size { + panic!( + "Invalid histogram buffer access: Index {} is out of range for histogram buffer of size {}", + index, self.size + ); + } + &self.data[index] + } +} +impl IndexMut for HistogramBuffer { + fn index_mut(&mut self, index: usize) -> &mut Self::Output { + if index >= self.size { + panic!( + "Invalid histogram buffer access: Index {} is out of range for histogram buffer of size {}", + index, self.size + ); + } + &mut self.data[index] + } +} diff --git a/src/utils/buffers/mod.rs b/src/utils/buffers/mod.rs index 45f8437..2564ab9 100644 --- a/src/utils/buffers/mod.rs +++ b/src/utils/buffers/mod.rs @@ -2,6 +2,7 @@ pub mod minima_buffer; pub mod peak_buffer; pub mod ring_buffer; pub mod waveform_buffer; +pub mod histogram_buffer; use std::ops::{Index, IndexMut}; @@ -9,6 +10,7 @@ pub use minima_buffer::MinimaBuffer; pub use peak_buffer::PeakBuffer; pub use ring_buffer::RingBuffer; pub use waveform_buffer::WaveformBuffer; +pub use histogram_buffer::HistogramBuffer; pub trait VisualizerBuffer: Index + IndexMut { /// Enqueues an element. diff --git a/src/visualizers/histogram.rs b/src/visualizers/histogram.rs new file mode 100644 index 0000000..b690675 --- /dev/null +++ b/src/visualizers/histogram.rs @@ -0,0 +1,159 @@ +use super::{FillFrom, FillModifiers, RangeModifiers}; +use crate::utils::{HistogramBuffer, ValueScaling, VisualizerBuffer}; + +use nih_plug_vizia::vizia::{prelude::*, vg}; +use std::sync::{Arc, Mutex}; + +/// Real-time histogram displaying information that is stored inside a [`HistogramBuffer`] +/// +/// # Example +/// +/// Here's how to set up a histogram. For this example, you'll need a +/// [`HistogramBuffer`](crate::utils::HistogramBuffer) to store your histogram information. +/// +/// ``` +/// Histogram::new(cx, Data::histogram_buffer, (-32.0, 8.0), 0.1) +/// .color(Color::rgba(0, 0, 0, 160)) +/// .background_color(Color::rgba(0, 0, 0, 60)); +/// ``` +/// +/// The histogram displays the range from -32.0dB to 8dB. +/// it decays as 0.1 TODO, and a stroke and fill (background) color is provided. +pub struct Histogram +where + L: Lens>>, +{ + buffer: L, + range: (f32, f32), +} + +enum HistogramEvents { + UpdateRange((f32, f32)), + // UpdateDecay(f32), +} + +impl Histogram +where + L: Lens>>, +{ + pub fn new(cx: &mut Context, buffer: L, range: impl Res<(f32, f32)> + Clone) -> Handle { + Self { + buffer, + range: range.get_val(cx), + } + .build(cx, |_| {}) + .range(range) + } +} + +impl View for Histogram +where + L: Lens>>, +{ + fn element(&self) -> Option<&'static str> { + Some("histogram") + } + fn event(&mut self, _cx: &mut EventContext, event: &mut Event) { + event.map(|e, _| match e { + HistogramEvents::UpdateRange(v) => self.range = *v, + // HistogramEvents::UpdateDecay(s) => self.decay = *s, + }); + } + fn draw(&self, cx: &mut DrawContext, canvas: &mut Canvas) { + let bounds = cx.bounds(); + + let line_width = cx.scale_factor(); + + let x = bounds.x; + let y = bounds.y; + let w = bounds.w; + let h = bounds.h; + + let mut stroke = vg::Path::new(); + let binding = self.buffer.get(cx); + let bins = &(binding.lock().unwrap()); + let nr_bins = bins.len(); + + let mut largest = 0.0; + // don't scale to bins[0] + for i in 1..nr_bins { + if bins[i] > largest { + largest = bins[i]; + } + } + + // start of the graph + stroke.move_to(x + bins[nr_bins - 1] * w, y); + + // the actual histogram + if largest > 0.0 { + for i in 1..nr_bins { + stroke.line_to( + x + ( + // scale so the largest value becomes 1. + (bins[nr_bins - i] / largest) * w + ), + y + h * i as f32 / (nr_bins - 1) as f32, + ); + } + } + // fill in with background color + let mut fill = stroke.clone(); + fill.line_to(x, y + h); + fill.line_to(x, y); + fill.close(); + canvas.fill_path(&fill, &vg::Paint::color(cx.background_color().into())); + + canvas.stroke_path( + &stroke, + &vg::Paint::color(cx.font_color().into()).with_line_width(line_width), + ); + } +} + +impl<'a, L> FillModifiers for Handle<'a, Histogram> +where + L: Lens>>, +{ + // stubs + fn fill_from_max(self) -> Self { + self + } + fn fill_from_value(self, level: f32) -> Self { + self + } +} + +impl<'a, L> RangeModifiers for Handle<'a, Histogram> +where + L: Lens>>, +{ + fn range(mut self, range: impl Res<(f32, f32)>) -> Self { + let e = self.entity(); + + range.set_or_bind(self.context(), e, move |cx, r| { + (*cx).emit_to(e, HistogramEvents::UpdateRange(r.clone())); + }); + + self + } + + fn scaling(mut self, scaling: impl Res) -> Self { + // let e = self.entity(); + + // scaling.set_or_bind(self.context(), e, move |cx, s| { + // (*cx).emit_to(e, GraphEvents::UpdateScaling(s.clone())) + // }); + + self + } + // fn decay(mut self, decay: impl Res) -> Self { + // let e = self.entity(); + + // decay.set_or_bind(self.context(), e, move |cx, s| { + // (*cx).emit_to(e, HistogramEvents::UpdateDecay(s.clone())) + // }); + + // self + // } +} diff --git a/src/visualizers/mod.rs b/src/visualizers/mod.rs index 58af505..445bb65 100644 --- a/src/visualizers/mod.rs +++ b/src/visualizers/mod.rs @@ -8,6 +8,7 @@ mod oscilloscope; mod spectrum_analyzer; mod unit_ruler; mod waveform; +mod histogram; pub use graph::*; pub use grid::*; @@ -17,6 +18,7 @@ pub use oscilloscope::*; pub use spectrum_analyzer::*; pub use unit_ruler::*; pub use waveform::*; +pub use histogram::*; use super::utils::ValueScaling; use nih_plug_vizia::vizia::binding::Res;