Writing a CLAP synthesizer in Rust (Part 3)
It’s been quite some time now since I published part 2. There’s many reasons for this of course, but one of them was that when looking into how GUIs work in CLAP plugins I kind of expected to be able to register a callback that would get called each time a new frame was requested, but it apparently does not work like that.
Getting started
Like the last part we need to start with enabling more feature flags in
clack-extensions
using cargo.
cargo add --features gui,raw-window-handle_05 clack-extensions
And as in all previous posts, we should register a new extension, this time
PluginGui
.
fn declare_extensions(builder: &mut PluginExtensions<Self>, shared: Option<&Self::Shared<'_>>) {
builder
.register::<PluginAudioPorts>()
.register::<PluginNotePorts>()
.register::<PluginParams>()
.register::<PluginState>()
.register::<PluginGui>();
}
For the GUI we will now need to implement PluginGuiImpl
for our
CrabHowlerMainThread
. This is quite a big trait, so I think I will go through
it a few functions at a time so I can explain things step by step.
Let’s start with is_api_supported
and get_preferred_api
, which should have
different behavior depending on what platform we’re running on. The preferred
way is to create an embedded window, meaning that we (the plugin) are
responsible for creating the window and then embedding it into the hosts (the
DAW) window. This is however not supported on Wayland, so for now we will
just ignore that for simplicity, and focus our efforts on Win32, Cocoa and X11.
Conveniently clack-extensions
already comes with a method that will return
Win32 on Windows, Cocoa on macOS and X11 on anything Unix, making the
implementation straightforward.
impl<'a> PluginGuiImpl for CrabHowlerMainThread<'a> {
fn is_api_supported(&mut self, configuration: GuiConfiguration) -> bool {
configuration.api_type
== GuiApiType::default_for_current_platform().expect("Unsupported platform")
&& !configuration.is_floating
}
fn get_preferred_api(&mut self) -> Option<GuiConfiguration> {
Some(GuiConfiguration {
api_type: GuiApiType::default_for_current_platform().expect("Unsupported platform"),
is_floating: false,
})
}
...
Picking a GUI library
I spent some time looking around at different UI libraries to get a feel for what I should use, but in the end I think egui will be the simplest one to set up, and it should also give us very simple rendering and state handling since it’s immediate mode.
egui
itself does not come with any window handling, and while I would normally
use winit
for that, it apparently
does not support wrapping
an already existing window, which is exactly what we need to do when the DAW
gives us a window handle. Instead I opted for
baseview and
egui-baseview.
cargo add --git https://github.com/RustAudio/baseview.git --rev 9a0b42c09d712777b2edb4c5e0cb6baf21e988f0 baseview
cargo add --git https://github.com/BillyDM/egui-baseview.git
cargo add raw-window-handle@0.5.2
These are added as git dependencies because from what I can see they are not published on crates.io.
Setting up the UI
In order to keep our constantly growing lib.rs
file in check, lets put the GUI
related code into a new gui.rs
file.
The only thing we need to keep track of are the parent window handle that will
be given to us by the DAW and our own baseview
handle.
pub struct CrabHowlerGui {
pub parent: Option<RawWindowHandle>,
handle: Option<WindowHandle>,
}
impl Default for CrabHowlerGui {
fn default() -> Self {
Self {
parent: None,
handle: None,
}
}
}
As for implementation, we will only need open
and close
for this minimal
example.
impl CrabHowlerGui {
pub fn open(&mut self, state: &CrabHowlerShared) -> Result<(), PluginError> {
if self.parent.is_none() {
return Err(PluginError::Message("No parent window provided"));
}
let settings = WindowOpenOptions {
title: "CrabHowler".to_string(),
size: Size::new(400.0, 200.0),
scale: WindowScalePolicy::SystemScaleFactor,
gl_config: Some(Default::default()),
};
self.handle = Some(EguiWindow::open_parented(
self,
settings,
GraphicsConfig::default(),
state.envelope.clone(),
|_egui_ctx: &Context, _queue: &mut Queue, _state: &mut Arc<RwLock<Envelope>>| {},
|egui_ctx: &Context, _queue: &mut Queue, state: &mut Arc<RwLock<Envelope>>| {
let mut envelope = state.write().unwrap();
egui::CentralPanel::default().show(egui_ctx, |ui| {
ui.heading("Crab Howler");
ui.add(Slider::new(&mut envelope.attack, 0.0..=1.0).text("Attack"));
ui.add(Slider::new(&mut envelope.decay, 0.0..=1.0).text("Decay"));
ui.add(Slider::new(&mut envelope.sustain, 0.0..=1.0).text("Sustain"));
ui.add(Slider::new(&mut envelope.release, 0.0..=1.0).text("Release"));
});
},
));
Ok(())
}
pub fn close(&mut self) {
if let Some(handle) = self.handle.as_mut() {
handle.close();
self.handle = None;
}
}
}
Note that we’re passing CrabHowlerShared
to the open
function, this will be
clearer in a bit, but it’s essentially because egui_baseview
require the state
to be 'static + Send
, which we fulfill by using an Arc
.
We’ll not be doing any fancy custom controls or special rendering, as this is only intended as an example of how to get basic GUI functionality running, so we just set up 4 sliders for the ADSR values.
EguiWindow::open_parented
takes anything HasRawWindowHandle
as it’s parent
argument, so we can just implement that for our type so we can pass self
there.
unsafe impl HasRawWindowHandle for CrabHowlerGui {
fn raw_window_handle(&self) -> RawWindowHandle {
self.parent.unwrap()
}
}
Hooking it up with CLAP
Let’s move back to lib.rs
now and add this new type as a member to our main
thread struct.
pub struct CrabHowlerMainThread<'a> {
shared: &'a CrabHowlerShared,
gui: CrabHowlerGui,
}
This will require us to update new_main_thread
as well, but since we
implemented Default
for our new GUI type this is simple.
fn new_main_thread<'a>(
host: HostMainThreadHandle<'a>,
shared: &'a Self::Shared<'a>,
) -> Result<Self::MainThread<'a>, PluginError> {
Ok(Self::MainThread {
shared,
gui: CrabHowlerGui::default(),
})
}
We can then continue the implementation of PluginGuiImpl
. Most of the
functions that the interface require of us can be left empty for simplicity,
with the important ones being set_parent
, show
and hide
.
...
fn create(
&mut self,
configuration: clack_extensions::gui::GuiConfiguration,
) -> Result<(), PluginError> {
Ok(())
}
fn destroy(&mut self) {}
fn set_scale(&mut self, scale: f64) -> Result<(), PluginError> {
Ok(())
}
fn get_size(&mut self) -> Option<clack_extensions::gui::GuiSize> {
Some(clack_extensions::gui::GuiSize {
width: 400,
height: 200,
})
}
fn set_size(&mut self, size: clack_extensions::gui::GuiSize) -> Result<(), PluginError> {
Ok(())
}
fn set_parent(&mut self, window: clack_extensions::gui::Window) -> Result<(), PluginError> {
self.gui.parent = Some(window.raw_window_handle());
Ok(())
}
fn set_transient(&mut self, window: clack_extensions::gui::Window) -> Result<(), PluginError> {
Ok(())
}
fn show(&mut self) -> Result<(), PluginError> {
self.gui.open(&self.shared)?;
Ok(())
}
fn hide(&mut self) -> Result<(), PluginError> {
self.gui.close();
Ok(())
}
}
We will also need to wrap our envelope
in an Arc
like I mentioned earler.
pub struct CrabHowlerShared {
envelope: Arc<RwLock<Envelope>>,
}
The result
We’re finally ready to build it and test it out in a DAW.
It may not be fancy, but it’s drawn entirely by the plug itself.
Getting the code
As always the code is available by browsing the part-3
tag over at
github.com/Kwarf/crabhowler.
Conclusion
I don’t think there will ever be a part 4 in this series. While it was a fun exercise and a good look into how CLAP works, I think it would be much easier and cleaner to use something slightly higher level for most plugins, like nih-plug. I’m also constantly fighting urges to refactor the code, and to fix things like the slightly wonky ADSR handling causing clicks, but this feels hard to do in blog form and I don’t want the source tree to dramatically change from one post to another.
If I do end up continuing my exploration of plugins I will likely focus more on the actual audio side of it, and less on frameworks.