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
andOscilloscopeBuffer
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 graphcolor
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)); }) } }