Revisiting Async and the RtMidi Event Loop
25 Feb 2026
Introduction
A coupla few years ago I wrote about Integrating RtMidi's event loop with IO::Async. The basic approach involved spawning a routine which set up a RtMidi callback to pass MIDI messages back to the main process via a channel. This routine then went to sleep to allow RtMidi's own event loop to take control.
While this approach works well enough, and has been used in a number of projects (such as The MIDI device filter project on this site, and Gene Boggs' MIDI::RtController ecosystem), it also adds no small amount of complexity and overhead to an implementation. Running Perl-based callbacks within the rtmidi callback-handler thread can also cause crashes, if not done carefully.
While playing around with libremidi, I noticed it offers an option which makes a nonblocking file descriptor available for integration into your event loop, and found myself thinking that it would be nice to have RtMidi offer a similar facility. What if...
What if we don't set RtMidi's callback to a
FFI::Platypus::Closure
wrapping our Perl code, but instead set the callback within some bundled C
code? This callback would create a fd pair - two file descriptors attached to
either end of a
pipe, a
write fd and a read fd. The read fd could be set nonblocking and, as it's a
simple int, be trivially passed back to our Perl program for later
integration into any event loop. This would eliminate the problems of calling
Perl code in new threads, and should simplify event loop integration - no more
routines and channels required, we can now process MIDI bytes as a stream.
This seems sound enough to me, but we are now left with the problem of bundling C - Makefiles, compilers, actually writing C ... We are surely in for a world of pain, or are we?
Bundling Native Code with FFI::Platypus
FFI::Platypus' bundling interface allows for seamless inclusion of C code in your distribution (or Go code, or Rust code, or Zig code, or...), without needing to worry about the build system too much. The moving parts in our case are:
- A directive to load bundled code alongside other Platypus library bindings.
- Some C code in a directory named
ffi/in the root of the distribution. - A
.fbxfile to pass some config values toFFI::Buildand set the bundled dynamic library name. - An addition to
dist.inito instruct FFI::Build to build bundled code when installing from CPAN.
This last part is pretty simple, the following line is added to dist.ini:
[FFI::Build]Let's take a look at the C.
Creating the pipe
Let's start with the includes. You'll always want ffi_platypus_bundle.h for
bundled FFI code. There are some libc parts, then rtmidi_c.h, RtMidi's C
interface header. This will be useful for dereferencing RtMidi device members,
as we'll see later.
#include <ffi_platypus_bundle.h>
#include <unistd.h>
#include <stdio.h>
#include <fcntl.h>
#include <rtmidi_c.h>Next comes some cheating ... while this is ostensibly C, we will need to build
and link with a C++ compiler to access RtMidi's namespaces correctly. This is
as simple as giving our file a .cpp extension (ffi/pipefd.cpp), and placing
our C code in an extern block covering the remainder of the file.
#ifdef __cplusplus
extern "C" {
#endifNext is a struct called _cb_descriptor, which at the moment just stores the writefd which will be used in RtMidi's callback.
typedef struct {
int fd;
} _cb_descriptor;The callback itself is a simple loop which attempts to write all available
bytes in *message to the writefd passed in the userdata *data. Passing
*data into the callback is a common pattern in C to work around the lack of
closure support.
The important thing to note here is that our callback is no longer Perl wrapped in a FFI::Platypus::Closure - our Perl code no longer needs to cede control to RtMidi.
void _callback( double deltatime, const char *message, size_t size, _cb_descriptor *data ) {
if( size < 1 ) {
return;
}
int total = 0;
int remains = size;
while ( total < size ) {
int sent = write( data->fd, message + total, remains );
if ( sent < 0 ) {
perror("Callback write error.");
return;
}
remains -= sent;
total += sent;
}
}The last part is to create the fd pair, stash the writefd fd into the
device's userdata, set the readfd side of the pipe nonblocking, register the
callback and userdata with RtMidi, then pass the readfd back to our Perl
program.
RTMIDIAPI is a handy macro from RtMidi which will handle symbol exporting in a cross-platform way.
RTMIDIAPI
int callback_fd( RtMidiInPtr device ) {
_cb_descriptor *data = (_cb_descriptor*)malloc( sizeof( _cb_descriptor ) );
int pipefd[2] = { 0, 0 };
if ( pipe(pipefd) < 0 ) {
perror("Cannot create pipe!");
return -1;
}
fcntl( pipefd[0], F_SETFL, O_NONBLOCK );
data->fd = pipefd[1];
rtmidi_in_set_callback( device, (RtMidiCCallback)&_callback, data );
return pipefd[0];
}Building and Binding
The .fbx file required to build this is called ffi/rtmidi-ffi.fbx (so the
dynamic library it builds will be called librtmidi-ffi.so/rtmidi-ffi.dll
etc. depending on platform). It does some linker flag setup for Windows and
MacOS, sets an 'Alien' package the compiler should use to find headers and
libraries from a non-system install, then sets the source to all .cpp files in ffi/ - there is
currently just one, ffi/pipefd.ccp.
our $DIR;
my $WINDOWS = $^O eq 'MSWin32' || $^O eq 'cygwin';
my $MACOS = $^O eq 'darwin';
my $libs = '';
$libs = '-lrtmidi -lwinmm -lws2_32' if $WINDOWS;
$libs = '-framework CoreFoundation -framework CoreMidi' if $MACOS;
{
alien => ['Alien::RtMidi'],
source => ["$DIR/*.cpp"],
libs => $libs,
};Something to note about the .fbx file is, it's probably not needed in many
cases. If your bundled code is portable enough, and doesn't have external dependencies,
you can likely get away without it.
There are a couple of moving parts needed back in Perl land to connect all this
up. First we tell Platypus to build and load the bundled code - this will
automagically build the bundle as it changes, even in a development environment
- no need to invoke make or similar:
my $ffi = FFI::Platypus->new( api => 2, ... );
$ffi->bundle;Then we need to attach the C function exported from our bundle:
$ffi->attach( callback_fd => [ [ 'RtMidiInPtr*' ] => 'int' ] )Finally, an exported Perl function is added to the binding library to wrap the fd integer returned from C in a readonly IO::Handle. This handle can then be passed easily to event loops:
sub callback_fh( $dev ) {
IO::Handle->new->fdopen( callback_fd( $dev ), 'r' );
}Windows
Keen-eyed Windows API experts may have spotted an issue with the above. Windows
does not have pipe(2). This winds up creating something of a mess, but the
short version is for Windows, a
socketpair is created in
Perl, the write-end is passed to C, and the read end is returned to the calling
program.
Examples
We should now have all the pieces to pass a filehandle to any event loop. As previous efforts used IO::Async, let's start there.
IO::Async
After MIDI device, decoder, and event loop setup, an
IO::Async::Stream is created with
the wrapped fd opened in our C code above. We can then attach a callback to the
decoder instance to print incoming events, then pass incoming bytes to the
decoder in async sub read_stream - as complete MIDI events are received, the
decoder's callback will be invoked.
A ticking counter is added to demonstrate other activity - we are not blocked
waiting for RtMidi, there is no need to sleep. Where previously we would have
had to create a routine, and a channel for inter-process communication, here
the decoding of incoming MIDI messages is taken care of by the
stream's on_read handler within
a single thread.
use IO::Async::Loop;
use IO::Async::Stream;
use IO::Async::Timer::Periodic;
use MIDI::RtMidi::FFI::Device;
use MIDI::Stream::Decoder;
my $midi_in = RtMidiIn->new();
$midi_in->open_port_by_name( qr/sz|lkmk3/i );
my $fh = $midi_in->get_fh;
my $decoder = MIDI::Stream::Decoder->new;
$decoder->attach_callback( all => sub( $event ) {
say join ' ', $event->dt, $event->as_arrayref->@*;
} );
my $loop = IO::Async::Loop->new;
my $stream = IO::Async::Stream->new(
read_handle => $fh,
on_read => sub( $self, $buffref, $eof ) {
$decoder->decode( $$buffref );
$$buffref = "";
}
);
$loop->add( $stream );
my $tick = 0;
$loop->add( IO::Async::Timer::Periodic->new(
interval => 1,
on_tick => sub { say "Tick " . $tick++; },
)->start );
$loop->run;Mojo::IOLoop
A very similar approach can be used with Mojo::IOLoop and Mojo::IOLoop::Stream:
use Mojo::IOLoop;
use MIDI::RtMidi::FFI::Device;
use MIDI::Stream::Decoder;
my $midi_in = RtMidiIn->new();
$midi_in->open_port_by_name( qr/sz|lkmk3/i );
my $fh = $midi_in->get_fh;
my $decoder = MIDI::Stream::Decoder->new;
$decoder->attach_callback( all => sub( $event ) {
say join ' ', $event->dt, $event->as_arrayref->@*
} );
my $stream = Mojo::IOLoop::Stream->new( $fh );
$stream->timeout( 0 );
$stream->on(
read => sub ( $stream, $midi_bytes ) {
$decoder->decode( $midi_bytes );
}
);
$stream->start;
my $tick = 0;
Mojo::IOLoop->recurring( 1 => sub { say "Tick " . $tick++; } );
Mojo::IOLoop->start unless Mojo::IOLoop->is_running;Future::IO
Future::IO is a solid option if you want
you event-driven code to be loop-agnostic. The same Future::IO code can be
integrated into programs using one of any number of event loops by loading an
IO implementation, e.g.
Future::IO::Impl::Glib.
use Future::IO;
use Future::AsyncAwait;
use Time::HiRes qw/ time /;
use MIDI::RtMidi::FFI::Device;
use MIDI::Stream::Decoder;
my $midi_in = RtMidiIn->new();
$midi_in->open_port_by_name( qr/sz|lkmk3/i );
my $fh = $midi_in->get_fh;
my $decoder = MIDI::Stream::Decoder->new;
$decoder->attach_callback( all => sub( $event ) {
say join ' ', $event->dt, $event->as_arrayref->@*
} );
async sub msg {
my $size = $midi_in->bufsize;
while ( my $midi_bytes = await Future::IO->read( $fh, $size ) ) {
$decoder->decode( $midi_bytes );
}
}
async sub tick {
my $tick = 0;
while ( 1 ) {
await Future::IO->alarm( time() + 1 );
say "Tick " . $tick++;
}
}
Future->wait_all( tick, msg )->get;This, to me, appears markedly simpler than the previous effort. There is no need to explicitly create routines, and no oddities around ceding control to RtMidi. It also has the benefit of being more robust, as Perl closures are not being called from external threads.
Sample output
The three examples above should produce equivalent output, which looks something like this:
Tick 0
0 note_on 0 55 64
Tick 1
0.838393 note_off 0 55 0
Tick 2
0.018346 control_change 0 1 63
0.040585 control_change 0 1 62
0.053563 control_change 0 1 61
Tick 3
Tick 4
Tick 5
2.357593 control_change 0 1 62
0.004254 control_change 0 1 63
0.004569 control_change 0 1 64
Tick 6We can see our ticking counter interleaved with incoming MIDI delta-times (time since previous event), and events from an external device.
Conclusion
After a review of previous work in integrating RtMidi with another event loop, we went
through some of the shortcomings of this approach, before describing a new
approach which uses pipes to pass MIDI bytes from RtMidi to a MIDI-processing
program. We took a quick tour of the C code, which uses FFI::Platypus::Bundle
to build and load a dynamic library to make itself available.
We took a look at some examples around how to integrate the fd created in C into event loops in Perl, which were marked by simpler implementation than previous async integration attempts.
The callback_fh mechanism and the examples in this post are now available in
MIDI::RtMidi::FFI v0.10 and above.