Rust ❤️ Bela – FFI API Design
Posted
Continuing with the current series of blog posts on using Rust to develop Bela projects, we have so far talked about setting up a Rust cross compilation project for Bela and the projects I plan to cover at some point, as well as some basic feasibility checks using portable SIMD code and criterion
. This time, we'll delve into the Bela C API, the reasoning behind the safe Rust FFI API, and also finally produce some sound.
So just what is an FFI? A foreign function interface (FFI), is how one programming language (in our case Rust) calls functions from another “foreign” programming language (typically C). So FFIs are needed whenever a piece of code in a new programming language has to interact with an existing API designed for a different programming language. Luckily, the Bela API is well documented and (mostly, some extensions have a C++ API) a C API, so no need to write C wrappers, which is typically necessary when two higher-level languages need to talk to one another. By the way, the link above points to the low-level Doxygen documentation of the Bela API. Generally, the Bela Knowledge Base is a better learning resource, just not for our current use case.
While Rust provides compile-time guarantees about memory safety, no undefined behavior, and freedom from data races, C gives no such guarantees. Nasal demons abound. So keep your Necronomicon… ahem… I meant your Rustonomicon at hand, since we'll be forced to delve into the realm of unsafe
Rust. Why? Because every call through an FFI is unsafe by definition, since the Rust compiler can't know which — if any — compile time guarantees the other language provides. We will have to ensure any and all guarantees ourselves. To this end, Rust FFI crates are typically designed in a two-level fashion:
- A
sys
-crate that makes the raw, unsafe C functions available to Rust and ensures the corresponding libraries are linked. - A safe wrapper crate that builds safe higher-level abstractions around the unsafe code, using a variety of techniques to ensure the preconditions of all API calls are fulfilled.
The latter may not be possible in all cases or require some clever tricks using Rust's far more advanced type system (and clever is rarely good), but we'll try our best.
The unsafe sys
-Crate
In any case, first things first: the sys
-crate. I won't go into too much detail about it, as it is mostly boring busywork, but if you are interested in more details, check out the bindgen
book, since bindgen
is the de facto standard tool for creating C FFI bindings for Rust. But let's have a quick look at andrewcsmith/bela-sys
, the sys
-crate for Bela. The crate root src/lib.rs
is incredibly simple:
#![allow(non_snake_case, non_camel_case_types, non_upper_case_globals)]
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
#[cfg(feature = "midi")]
pub mod midi {
include!(concat!(env!("OUT_DIR"), "/midi_bindings.rs"));
}
It disables some warnings on non-standard naming conventions (which are common in C), then includes some files as a string using the include!
macro (yes, Rust has a #include
-like mechanism too for when it is necessary). The second of which is optional and only included when the midi
feature is requested. The file paths themselves are constructed by concatenating the environment variable OUT_DIR
, which is defined by Cargo, with the corresponding file name. But who or what generates these files? That would be the build.rs
build script, which lies in the crate root, next to Cargo.toml
, and does all the heavy lifting. So let's have a look-see.
let bela_root = PathBuf::from(
env::var("BELA_SYSROOT").unwrap_or_else(|_| "/".into()),
);
let bela_include = bela_root.join("root/Bela/include");
let bela_h = bela_include.join("Bela.h");
if !bela_h.exists() {
panic!(
"Set BELA_SYSROOT to the location of your extracted Bela \
image"
);
}
let bela_lib = bela_root.join("root/Bela/lib");
let xenomai_lib = bela_root.join("usr/xenomai/lib");
let local_lib = bela_root.join("usr/local/lib");
The first part of our build script tries to read the BELA_SYSROOT
environment variable, which tells the crate where to look for all required headers and dependencies when cross compiling (on the Bela itself /
should do), then constructs a number of paths relative to this root and checks if Bela.h
is found. For documentation on how to extract the Bela image to create the sysroot folder, check out my newly released cargo-generate
template for Bela projects: l0calh05t/bela-template
.
#[cfg(feature = "static")]
{
println!("cargo:rustc-link-lib=static=bela");
println!("cargo:rustc-link-lib=stdc++");
}
#[cfg(not(feature = "static"))]
{
println!("cargo:rustc-link-lib=bela");
}
println!("cargo:rustc-link-lib=cobalt");
println!("cargo:rustc-link-lib=prussdrv");
println!(
"cargo:rustc-link-search=all={}",
bela_lib.to_str().unwrap()
);
println!(
"cargo:rustc-link-search=all={}",
xenomai_lib.to_str().unwrap()
);
println!(
"cargo:rustc-link-search=all={}",
local_lib.to_str().unwrap()
);
Here, the build script tells Cargo (and in turn rustc
) which libraries to link (cargo:rustc-link-lib
) and where to find them (cargo:rustc-link-search
). The main dependency (bela
) should also be listed in the links
field of the Cargo.toml
. This is also where the second of the crate's two features, static
, comes into play, to decide if we want to link libbela.a
(a static archive) or libbela.so
(a dynamically linked shared object).
let bindings = bindgen::Builder::default()
.header(bela_h.to_str().unwrap())
.clang_arg(format!("--sysroot={}", bela_root.to_str().unwrap()))
.clang_arg(format!("-I{}", bela_include.to_str().unwrap()))
.allowlist_type("Bela.*")
.allowlist_function("Bela_.*")
.blocklist_function("Bela_userSettings")
.allowlist_function("rt_.*printf")
.allowlist_var("BELA_.*")
.allowlist_var("DEFAULT_.*")
.generate()
.expect("Unable to generate bindings");
let out_path = PathBuf::from(env::var("OUT_DIR").unwrap());
bindings
.write_to_file(out_path.join("bindings.rs"))
.expect("Couldn't write bindings");
This is where the magic happens, and we tell bindgen
for which file it should generate bindings. Additionally, it needs to know where to find other headers recursively included by Bela.h
. We also perform some filtering using allow- and blocklisting, since these recursive includes also declare a bunch of stuff not related to Bela at all. And bindgen
can't really differentiate here, as C doesn't support anything like namespaces. Leading to naming conventions like all functions and types being prefixed with Bela_
and constants with BELA_
, as is the case here. Of course, like all conventions, there are some exceptions. For example, we explicitly don't want Bela_userSettings
as this is only provided as a weak symbol and the user is supposed to override it. Finally, we write the generated bindings to a file. The one we included in the root module in lib/src.rs
.
#[cfg(feature = "midi")]
{
let midi_root = bela_root.join("root/Bela/libraries/Midi");
// TODO: asound is in usr/lib/arm-linux-gnueabihf, so copy it to
// OUT_DIR, as otherwise the link step will also find
// libpthread.so and other link scripts there which reference
// absolute paths. Is there a cleaner solution?
let asound_lib =
bela_root.join("usr/lib/arm-linux-gnueabihf/libasound.so");
let asound_copy = out_path.join("libasound.so");
if !asound_copy.exists() {
std::fs::copy(asound_lib, asound_copy).unwrap();
}
cc::Build::new()
.cpp(true)
.object(midi_root.join("build/Midi_c.o"))
.object(midi_root.join("build/Midi.o"))
.compile("midi");
println!("cargo:rustc-link-lib=asound");
println!("cargo:rustc-link-lib=modechk");
let midi_h = midi_root.join("Midi_c.h");
let bindings = bindgen::Builder::default()
.header(midi_h.to_str().unwrap())
.clang_arg(format!(
"--sysroot={}",
bela_root.to_str().unwrap()
))
.clang_arg(format!("-I{}", bela_include.to_str().unwrap()))
.allowlist_function("Midi_.*")
.generate()
.expect("Unable to generate bindings");
bindings
.write_to_file(out_path.join("midi_bindings.rs"))
.expect("Couldn't write bindings");
}
}
The final part of our build script generates the (optional) bindings for Bela's MIDI support. These are slightly more complex and require the cc
crate in addition to bindgen
, since the MIDI support is not provided as an archive or shared object, but as a pair of unlinked object files. Additionally, libasound.so
is in a folder that also contains some link scripts with absolute paths, which are invalid on our extracted image when cross compiling, so we copy it to our output folder (if you have a better suggestion, feel free to contact me). The rest is very similar to what we have seen before.
At this point, we could already use bela-sys
to write programs for our Bela, but almost everything would be unsafe
, and we'd have to make sure ourselves that we don't violate any invariants. If you really want to see how that looks, check out examples/hello.rs
. For now, let's just have a look at the first couple of lines of the main
-function:
fn main() {
unsafe {
let mut settings = {
let mut settings: mem::MaybeUninit<BelaInitSettings> =
mem::MaybeUninit::uninit();
bela_sys::Bela_defaultSettings(settings.as_mut_ptr());
settings.assume_init()
};
Every single line is unsafe. We have to deal with uninitialized memory (tip: never, ever, use std::mem::uninitialized
, as it cannot be used correctly), call an unsafe function, and then swear that what Bela_defaultSettings
did definitely initialized all fields of the BelaInitSettings
structure settings
. Otherwise? Nasal demons. And the compiler can do whatever it wants with our code. Also, that unsafe
block? It closes at the end of main, as practically all of its 44 lines of code are unsafe. And the render
function (not included in those 44 lines), has to dereference raw pointers and accesses a static mut
variable. Yup, a global variable. Yuck. And totally unsafe, losing nearly all benefits of writing Rust in the first place. So let's not do that and look into creating a safe wrapper instead, ok?
The Safe Wrapper Crate
So how do we approach writing a safe wrapper around the raw, unsafe, C API? Well, that really depends on the API in question and what the (hopefully well-documented) preconditions for each function are! But first, let's take a step back and have a look at a standard C (well, technically C++ due to the bool
) Bela program:
#include <Bela.h>
bool setup(BelaContext *context, void *userData) {
// set up any required state, perform allocations, etc.
return true;
}
void render(BelaContext *context, void *userData) {
// read/write audio, analog, and digital signals from their
// respective buffers via context, update state, etc.
}
void cleanup(BelaContext *context, void *userData) {
// free any allocations made in setup, etc.
}
The first thing you may notice: there is no main
. So what is the entry point of our program? Well, Bela's IDE implicitly links against core/default_main.cpp
, which contains a bunch of Bela API calls to set everything up and uses the setup
, render
, and cleanup
functions declared (but not defined, the user has to do that) in Bela.h
to fill the setup
, render
, and cleanup
fields of a Bela_InitSettings
object, which are function pointers. And what about that userData
pointer? Most Bela examples do not use it at all, instead using global variables for pretty much everything. The horror. In fact, there is no way to use userData
without writing our own main
, since the pointer must be passed to Bela_initAudio
and core/default_main.cpp
passes 0
(i.e., a null pointer), so the C API doesn't exactly make it easy to avoid global variables. But accessing static mut
(i.e., mutable, global variables) is always unsafe (very easy to make mistakes and access them from another thread, for example), we need a different solution to achieve the goal of a safe Rust wrapper.
However, there is good news too. And that good news is that the API supports passing in an arbitrary userData
pointer, so we absolutely can design a safe API. It just can't involve simply writing three unconnected functions. So let's have a look at the safe wrapper l0calh05t/bela-rs:next
. Originally, I started out extending andrewcsmith/bela-rs
, but noticed that a fairly significant redesign was necessary to actually achieve a safe design, which is what I did in my next
branch. I may merge it (or detach the fork) at some point. Now let's look at the two most important methods in the crate, Bela::new
and Bela::run
.
impl<Application, Constructor> Bela<Constructor>
where
Application: BelaApplication,
Constructor: Send
+ UnwindSafe
+ FnOnce(&mut SetupContext) -> Option<Application>,
{
/// Create a new `Bela` builder with default settings from a
/// `BelaApplication` constructor function object
pub fn new(constructor: Constructor) -> Self {
Self {
settings: InitSettings::default(),
constructor,
}
}
/* We'll skip all the setter functions here */
/* ... */
/// Consumes the `Bela` object and runs the application
///
/// Terminates on error, or as soon as the application stops
pub fn run(self) -> Result<(), Error> {
/* We'll look into the innards of run later */
/* ... */
}
}
To deconstruct this a little: Bela
follows the builder pattern. Bela::new
creates a new builder, then a bunch of setters can be used to configure our program, then Bela::run
consumes the builder and runs our program. The impl
-block depends on two types: Application
and Constructor
. Application
is a type that implements the trait BelaApplication
, which we'll introduce in a moment. Constructor
is a type that implements
FnOnce(&mut SetupContext) -> Option<Application>
, which basically just means that it is a function or closure that takes a mutable reference to aSetupContext
and (maybe) creates an instance ofApplication
while being callable at least once (i.e., is consumed on call),Send
, which means that it has to support being sent (moved) to another thread (Bela doesn't specify ifsetup
is called in the original thread, so we have to err on the side of caution, since the render system may call it on its own thread), andUnwindSafe
, which is due to the fact, that Rust code canpanic!
and unwind the stack (similar to a C++ exception), but unwinding across an FFI boundary is undefined behavior, so we have to catch any panics, which requires the code to beUnwindSafe
(this trait is usually automatically implemented).
Ok, that was a lot to digest, but the rest is pretty simple. Bela::new
just creates a new Bela
from a defaulted (i.e., with bela_sys::Bela_defaultSettings
) InitSettings
, an internal wrapper around BelaInitSettings
that takes care of the unsafe initialization and deallocates on drop, and a Constructor
. Bela::run
then consumes our Bela
and generates nothing or an error, just like your typical main
function. As you may have guessed, our Constructor
function is run in setup
, creating an Application
. But what about render
and cleanup
? Well, render
is simply the one member of our trait BelaApplication
!
pub unsafe trait BelaApplication: Sized + Send {
fn render(&mut self, context: &mut RenderContext);
}
Wait. Aren't we writing a safe wrapper? Why is the trait unsafe
, and what does that even mean? An unsafe
trait is a trait which is unsafe to implement if you don't ensure some invariants are fulfilled that the compiler can't check. And the big one here is that render
(which in itself is a safe method!) must be real-time safe, as per the official documentation. What does that mean? Basically, since the rendering thread is higher priority than all operating system threads, any and all system calls must be avoided. That includes, but is not limited to:
- Allocation, so no
Box::new
inrender
. - Printing, so not even
println!
is allowed inrender
. - File I/O, so no accessing the SD card to read samples in
render
.
Doing any of these would incur a mode switch, which “can cause audio irregularities, sensor processing problems, or can cause the system to stop altogether.” Especially that last one is why that unsafe
is here. Also, since we can't allocate, we also can't catch_unwind
any panics, since that creates a boxed (heap-allocated) error. And since we can't catch the panic and unwinding across the FFI boundary is undefined behavior (there is work being done to alleviate this issue and define cross-language unwinding, which may solve this at some point), so any and all panics are verboten (strictly forbidden). We already looked at Send
earlier and render
is definitely in a separate thread. Sized
is another automatic marker trait that indicates that the size is known at compile time, i.e., it isn't a dynamic trait object, which can affect things like pointer size and generally make our life difficult when dealing with FFIs.
What about cleanup
? Rust already has a clearly defined way to clean up used resources. The Drop
trait! So all the cleanup
function has to do, is to consume the Application
instance, and automatically call any drop handlers while doing so. No need for you, the bela:next
user, to do anything! Putting it all together, you'd just write something like:
use bela::{Bela, BelaApplication, Error, RenderContext};
struct Example(usize);
unsafe impl BelaApplication for Example {
fn render(&mut self, context: &mut RenderContext) {
let audio_out_channels = context.audio_out_channels();
for frame in context
.audio_out()
.chunks_exact_mut(audio_out_channels)
{
let gain = 0.5;
let signal = 2. * (self.0 as f32 * 110. / 44100.) - 1.;
self.0 += 1;
if self.0 as f32 > 44100. / 110. {
self.0 = 0;
}
for sample in frame {
*sample = gain * signal;
}
}
}
}
fn main() -> Result<(), Error> {
Bela::new(|_context| Some(Example(0))).run()
}
Where RenderContext
is a wrapper for BelaContext
, allowing safe access to analog, audio, and digital buffers. But how do all these Rust functions connect to the unsafe API? That all happens in Bela::run
:
pub fn run(self) -> Result<(), Error> {
let Self {
mut settings,
constructor,
} = self;
let mut user_data: UserData<Application, _> =
UserData::Constructor(constructor);
In this first part, self
is decomposed into its parts using pattern matching, and a UserData
instance is created from the constructor. UserData
is just an enum
that can hold a constructor, an application, or nothing at all, a bit like a ternary Option
type. The next thing we need is a way to connect our Rust functions, methods, and closures to a C API. This is done using so-called trampoline functions (since they allow the program to jump — as in branch/call — somewhere they usually could not) that are defined with a C ABI/calling convention:
/// C-compatible trampoline function to call constructor
extern "C" fn setup_trampoline<Application, Constructor>(
context: *mut bela_sys::BelaContext,
user_data: *mut c_void,
) -> bool
where
Application: BelaApplication,
Constructor: Send
+ UnwindSafe
+ FnOnce(&mut SetupContext) -> Option<Application>,
{
// create application instance
// constructor is consumed
let user_data = unsafe {
&mut *(user_data
as *mut UserData<Application, Constructor>)
};
let constructor = user_data.take();
if let UserData::Constructor(constructor) = constructor {
*user_data = match catch_unwind(|| {
let mut context =
unsafe { Context::<SetupTag>::new(context) };
constructor(&mut context)
}) {
Ok(application) => application
.map_or(UserData::None, UserData::Application),
Err(_) => UserData::None,
};
}
user_data.is_application()
}
/// C-compatible trampoline function to call our render function
extern "C" fn render_trampoline<Application, Constructor>(
context: *mut bela_sys::BelaContext,
user_data: *mut c_void,
) where
Application: BelaApplication,
{
let user_data = unsafe {
&mut *(user_data
as *mut UserData<Application, Constructor>)
};
if let UserData::Application(user_data) = user_data {
// NOTE: cannot use catch_unwind safely here, as it
// returns a boxed error (-> allocation in RT thread)
let mut context =
unsafe { Context::<RenderTag>::new(context) };
user_data.render(&mut context);
};
}
/// C-compatible trampoline function to consume (and thereby
/// drop) our application object
extern "C" fn cleanup_trampoline<Application, Constructor>(
_context: *mut bela_sys::BelaContext,
user_data: *mut c_void,
) {
let _ = catch_unwind(|| {
// drop application instance
let user_data = unsafe {
&mut *(user_data
as *mut UserData<Application, Constructor>)
};
user_data.take();
});
}
// set up our trampoline functions as setup/render/cleanup
settings.setup =
Some(setup_trampoline::<Application, Constructor>);
settings.render =
Some(render_trampoline::<Application, Constructor>);
settings.cleanup =
Some(cleanup_trampoline::<Application, Constructor>);
This is fairly complicated, but follows the same pattern in all three instances. The user_data
pointer is reinterpreted as a pointer to UserData
and turned into a mutable reference, which is of course unsafe since the compiler doesn't know what that c_void
pointer points to, if we are really the only function to have access to it, or if it is non-null and valid. The context
pointer is wrapped in our safe Context
struct
and “tagged” to indicate in which function we currently are. This is also unsafe, since the compiler has no way of knowing if we are actually in setup
or render
. You'll see later why this is relevant. Finally, user_data
is “taken” (analogous to Option::take
, replacing it with a UserData::None
) and/or pattern matched to retrieve the contained constructor or BelaApplication
. Where possible, this is all protected with a catch_unwind
.
if unsafe {
bela_sys::Bela_initAudio(
settings.deref_mut() as *mut _,
&mut user_data as *mut _ as *mut _,
)
} != 0
{
return Err(Error::Init);
}
if unsafe { bela_sys::Bela_startAudio() } != 0 {
unsafe {
bela_sys::Bela_stopAudio();
bela_sys::Bela_cleanupAudio();
}
return Err(Error::Start);
}
setup_signal_handler();
while unsafe { bela_sys::Bela_stopRequested() == 0 } {
sleep(Duration::new(0, 100000));
}
unsafe {
bela_sys::Bela_stopAudio();
bela_sys::Bela_cleanupAudio();
}
Ok(())
}
The rest of Bela::run
takes care of actually running our program, by initializing the Bela audio system (Bela_initAudio
) using our settings struct
(the deref_mut
produces a Bela_InitSettings
reference from the wrapper InitSettings
) and our user_data
enum
. The latter requires a double pointer conversion: one to convert from &mut UserData
to *mut UserData
and one more to get from there to *mut c_void
. This is what we later get passed as user_data
in our callback trampolines, so the casts there are valid. After initializing the audio system, we need to start it (Bela_startAudio
), checking for errors as we go (always check the documentation if a zero or non-zero value is passed, no neat Result
type in C). Then we set up some signal handlers to request the audio system to stop on SIGINT
or SIGTERM
(using the nix
crate). After that? Wait until the program is done, by sleeping in a loop, 100 ms at a time. Then we clean up after ourselves and are done!
That's all of Bela::run
covered! Of course, there are lots of details we glossed over. For example, why do we tag our Context
wrapper depending on if we are in setup
or render
? There are two reasons for this:
- Several fields in the
BelaContext
struct
— in particular all input and output buffer pointers — are annotated with “this element is available inrender()
only.” So all the accessors that convert the raw pointers into appropriately sized, safe slices are only available inimpl Context<RenderTag>
, not in the genericimpl<T> Context<T>
. Even though Bela's auxiliary task API (and by extension the MIDI API since it uses an auxiliary task internally) doesn't require a
BelaContext
,Bela_createAuxiliaryTask
may only be called insetup
, so neither inrender
, nor before the audio system is initialized or after it is stopped. So while the original API doesn't require aBelaContext
to create a task, we implement task creation as a member ofimpl SetupContext
(an alias ofContext<SetupTag>
) to ensure it is called in a valid state:pub struct AuxiliaryTask(bela_sys::AuxiliaryTask); unsafe impl Send for AuxiliaryTask {} impl SetupContext { /// Create an auxiliary task that runs on a lower-priority thread /// /// # Safety /// `name` must be globally unique across all Xenomai processes, /// which cannot be verified at compile time pub unsafe fn create_auxiliary_task<Auxiliary>( &mut self, // unused reference to SetupContext, as this // should only be called in setup task: Box<Auxiliary>, priority: i32, name: &std::ffi::CStr, ) -> Result<AuxiliaryTask, Error> where Auxiliary: FnMut() + Send + 'static, { /* ... */ } }
This split of Context
into different types according to program state, is a very simple form of the typestate pattern, which encodes state in the type system to ensure at compile time that functions which require the program to be in a specific state can only be called in that state. To ensure that the user doesn't pass these contexts out of their respective functions, they are only passed in as mutable references, have no Clone
implementation, and all constructors are private to the bela
crate.
We won't go into the nitty-gritty details of auxiliary tasks, but here's a brief summary. Auxiliary tasks are basically lower-priority Xenomai threads, for slower tasks such as computing large FFTs. Creating them is slow and involves system calls, so it should never happen in the rendering thread, but they can be scheduled from either. Currently, scheduling is implemented on impl<T> Context<T>
for symmetry, but could just as well be on impl AuxiliaryTask
. The FnMut()
and Send
restrictions on task closures should be quite clear, but why the 'static
, indicating that they must live until the end of the program? One reason is the same as std::thread::spawn
, which also requires 'static
: threads can be detached and there is no way to force a join. Even if a join handle with appropriate Drop
implementation is returned, the user could explicitly leak that handle with std::mem::forget
, which is safe. Wait. Why is it safe? Leaking memory and not running drop handlers does not fall under Rust's safety rules (otherwise std::process::exit
would be illegal!). Leaking memory doesn't create any illegal memory accesses or double deletes, so it's ok! The second reason is a bit ugly: Bela doesn't provide an API to stop and destroy individual tasks (you can only stop and destroy all of them, but those functions aren't documented at this time), so internally, we have to box and leak the closure anyway. Oh, well. As an aside: 'static
isn't necessary on our BelaApplication
or on the constructor, since the user cannot interfere within Bela::run
, and it properly stops the audio system and calls all drop handlers.
Since MIDI support is based on an auxiliary task internally, a MIDI port can only be opened during setup as well. Internally, a Xenomai channel is used to communicate with the rendering thread, so reading is implemented on RenderContext
:
impl SetupContext {
pub fn new_midi(
&mut self,
port: &std::ffi::CStr
) -> Result<Midi, Error> {
/* ... */
}
}
impl RenderContext {
pub fn get_midi_message<'buffer>(
&mut self,
midi: &mut Midi,
buffer: &'buffer mut [u8; 3],
) -> Option<&'buffer [u8]> {
/* ... */
}
}
While get_midi_message
has some similarity to an iterator's next
method, new messages can be added to the queue later on, which doesn't really match iterator behavior, and we require a user-provided buffer. Looping over MIDI messages is simple enough with while let
, though:
let mut buf = [0u8; 3];
while let Some(msg) = context.get_midi_message(&mut self.midi, &mut buf) {
/* ... */
}
What's Left?
While we have covered the main APIs the user will interact with, we can't really cover everything here. So check out the documentation (cargo doc --no-deps --all-features --open
). There are a number of APIs I haven't wrapped yet either:
- Command line option handling. Bela provides
Bela_getopt_long
which implicitly handles existing Bela options, but it might be simpler to just re-implement it, than to wrap it in a safe API. - Audio level controls. Bela provides a number of functions to change ADC and DAC gains. Currently, these can only be set via the initial settings.
rt_printf
for real-time console output. Sinceprintln!
andeprintln!
can't be used inrender
, supportingrt_printf
would be very useful. However, checking that the parameters and format string fit together at compile time to ensure safety would require a somewhat complex procedural macro, as far as I can tell.- Support library APIs beyond MIDI, such as the GUI libraries. Most of these have to be linked separately (like MIDI) and many of them only have C++ APIs which require additional effort to bind. For the non-Bela specific APIs (for example for sound file I/O) it's easier to just use existing crates.
That's it for now, folks. Next time we'll look a little deeper into connecting MIDI support and our SIMD tone generation. As usual, feel free to follow me and send me a DM on Mastodon if you have any questions or comments. I'm also active on the Bela forum.