Cyma

Cyma is a collection of flexible, composable views that you can use to make any plug-in UI with ease. It uses various custom data structures for real-time visualizers, allowing you to easily build plug-in UIs that are performant. It works on top of VIZIA as a "view library" for your nih-plug UIs.

In this book, you'll learn how you can compose some common visualizers - and you will also learn the basics of how Cyma works along the way. It's still a work in progress, so expect it to grow and include more extensive examples over time.

Getting Started

Cyma is intended for use with nih-plug and VIZIA. To get started, just add it to your Cargo.toml.

  [dependencies]
  nih_plug = { ... }
  nih_plug_vizia = { ... }
+ cyma = { git = "https://github.com/223230/cyma" }

Then, you can use Cyma where you need it, by using cyma::prelude::*. This will import the most important parts of Cyma.

Composing a Peak Graph

In this guide, you'll learn how to compose this peak graph:

It will have a grid backdrop and a unit ruler. Also, it will be scaled by decibels and show all peak information of the last 10 seconds that is between -32dB and 8dB.

In order to layer the graph with a grid backdrop and a unit ruler, we'll use VIZIA's ZStack. And we will store the peak information inside a PeakBuffer - this is one of Cyma's own buffers.

Our starting point is a barebones nih-plug + VIZIA Plugin. It's assumed that you already know how to use both of these frameworks.

Setting up a PeakBuffer

Let's start off by adding a PeakBuffer to our Plug-in. This buffer will store the last 10 seconds of audio, which we will then pass to the editor to draw our peak graph.

Since we'll use this buffer to store samples on the plugin thread and to display a graph on the editor thread, we need to make it thread-safe. So let's just wrap it in an Arc<Mutex>>.

#![allow(unused)]
fn main() {
// lib.rs
pub struct PeakGraphPlugin {
    params: Arc<DemoParams>,
    peak_buffer: Arc<Mutex<PeakBuffer>>,
}
}

Now, we need to provide a default for this peak buffer inside the default() function. So we'll call PeakBuffer::new().

This function takes in a size, duration, and a decay. Based on these parameters, it creates a PeakBuffer. The buffer itself is usually kept quite small - we'll go with 800 samples over 10 seconds. As for our decay, we'll go with 50ms. This will make it still feel snappy, but extremely short peaks will be easier to make out.

#![allow(unused)]
fn main() {
// lib.rs
impl Default for PeakGraphPlugin {
    fn default() -> Self {
        Self {
            params: Arc::new(DemoParams::default()),
            peak_buffer: Arc::new(Mutex::new(PeakBuffer::new(800, 10.0, 50.0))),
        }
    }
}
}

Despite the buffer's small size, it will still store all relevant peak information. It does this by keeping track of the local maxima within the last 10 seconds of audio.

Other buffers, like the MinimaBuffer and OscilloscopeBuffer work in quite a similar way, where they accumulate local maxima and minima to retain information and avoid naïve downsampling.

There's a glaring issue with this, though. The host could set any sample rate, and the buffer would still work as if it's dealing with 44.1 kHz audio. So, let's set its sample rate to the actual host sample rate once we know it. We'll lock the peak_buffer mutex to gain access to the buffer, and then we'll set the sample rate.

#![allow(unused)]
fn main() {
// lib.rs
fn initialize(
    &mut self,
    _audio_io_layout: &AudioIOLayout,
    buffer_config: &BufferConfig,
    _context: &mut impl InitContext<Self>,
) -> bool {
    match self.peak_buffer.lock() {
        Ok(mut buffer) => {
            buffer.set_sample_rate(buffer_config.sample_rate);
        }
        Err(_) => return false,
    }

    true
}
}

Now, we can add our peak buffer to the editor's Data struct.

#![allow(unused)]
fn main() {
// editor.rs
#[derive(Lens, Clone)]
pub(crate) struct Data {
    peak_buffer: Arc<Mutex<PeakBuffer>>,
}

impl Data {
    pub(crate) fn new(peak_buffer: Arc<Mutex<PeakBuffer>>) -> Self {
        Self { peak_buffer }
    }
}
}

And finally, when we call the editor's create function from our plugin, we can pass the peak_buffer by cloning a reference to it.

#![allow(unused)]
fn main() {
// lib.rs
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
    editor::create(
        editor::Data::new(self.peak_buffer.clone()),
        self.params.editor_state.clone(),
    )
}
}

And just like that, we've now added a PeakGraph to our plug-in, to which the plug-in thread writes, and from which the editor thread reads. Great! We will now be able to use Views inside the editor to display our data.

Displaying a Graph

Now for the fun part! Let's add a graph to our editor. The Graph is a view that takes in a lens to some buffer, a range of values it can display, and a ValueScaling, so a type of scaling it should apply to the data.

We want our graph to show us the range of -32 dB up to 8 dB, and we want to scale our data as decibels. So, let's just add a Graph with exactly these parameters.

#![allow(unused)]
fn main() {
// editor.rs
pub(crate) fn create(editor_data: Data, editor_state: Arc<ViziaState>) -> Option<Box<dyn Editor>> {
    create_vizia_editor(editor_state, ViziaTheming::default(), move |cx, _| {
        assets::register_noto_sans_light(cx);
        editor_data.clone().build(cx);

        Graph::new(cx, Data::peak_buffer, (-32.0, 8.0), ValueScaling::Decibels);
    })
}
}

Next, we'll want to style our graph, and we could either do this via CSS or by using style modifiers. For colocation's sake, we'll go with style modifiers, but this really boils down to personal preference. We have two style modifiers at our disposal here; background_color and color.

  • background_color modifies the fill color of the graph
  • color modifies the stroke color of the graph

If we also want to change the color of the graph's backdrop, we can put it inside a ZStack and then change the stack's background color.

#![allow(unused)]
fn main() {
// editor.rs
pub(crate) fn create(editor_data: Data, editor_state: Arc<ViziaState>) -> Option<Box<dyn Editor>> {
    create_vizia_editor(editor_state, ViziaTheming::default(), move |cx, _| {
        assets::register_noto_sans_light(cx);
        editor_data.clone().build(cx);

        ZStack::new(cx, |cx| {
            Graph::new(cx, Data::peak_buffer, (-32.0, 8.0), ValueScaling::Decibels)
                .color(Color::rgba(255, 255, 255, 160))
                .background_color(Color::rgba(255, 255, 255, 60));
        })
        .background_color(Color::rgb(16, 16, 16));
    })
}
}

Adding Grid Lines

Now that we've already got a ZStack (a view that stacks its child views on top of each other), lets add some more views. We can add a Grid to display grid lines behind our graph. This view takes in a value scaling, a range, a vector of values where a grid line should be, and an orientation.

#![allow(unused)]
fn main() {
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));
}

So let's put it behind the graph! We'll add it as the first child of the ZStack, so that the graph gets drawn above it.

#![allow(unused)]
fn main() {
// editor.rs
pub(crate) fn create(editor_data: Data, editor_state: Arc<ViziaState>) -> Option<Box<dyn Editor>> {
    create_vizia_editor(editor_state, ViziaTheming::default(), move |cx, _| {
        assets::register_noto_sans_light(cx);
        editor_data.clone().build(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));

            Graph::new(cx, Data::peak_buffer, (-32., 8.), ValueScaling::Decibels)
                .color(Color::rgba(255, 255, 255, 160))
                .background_color(Color::rgba(255, 255, 255, 60));
        })
        .background_color(Color::rgb(16, 16, 16));
    })
}
}

This is nice, but we really need a unit ruler for this grid to be useful. Cyma's UnitRuler works in a similar way to the Grid - you specify a range of values, a scaling, and whether it should be oriented horizontally or vertically. However, instead of a vector of values, you pass a vector of tuples where for each element, the first tuple value is the position of each label on the ruler, and the second value is the text on each label.

For our UnitRuler, we'll just label every grid line, adhering to the scaling and range of our graph and grid.

#![allow(unused)]
fn main() {
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.));
}

Using an HStack, we can place this ruler next to the ZStack containing our graph and grid.

#![allow(unused)]
fn main() {
// editor.rs
pub(crate) fn create(editor_data: Data, editor_state: Arc<ViziaState>) -> Option<Box<dyn Editor>> {
    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));

                Graph::new(cx, Data::peak_buffer, (-32.0, 8.0), ValueScaling::Decibels)
                    .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));
    })
}
}