In Progress
Unit 1, Lesson 1
In Progress

FFI Part 1: Proof of the Concept

Video transcript & code

The audio subsystem on the Ubuntu OS is called "PulseAudio". For a while I've been wanting to be able to interact with PulseAudio from within Ruby. For instance, I'd like to be able to control which microphone is being used by my screen recording script. Of course, there are command line utilities for this. But translating data into and out of sub-processes is a finicky process, and not every PulseAudio feature is available from the command line. I'd like a closer connection to the library.

In this episode, we'll begin occasional series, documenting the process of wrapping the PulseAudio library with a Ruby API. In today's installment, we'll just go far enough to get a proof-of-concept script working. This script will simply print a list of the available audio input devices attached to the computer.

In years past I might have written a C extension in order to talk to a C library. But now we have the FFI gem, which enables C libraries to be called easily from Ruby, and works on all major Ruby implementations. FFI stands for "foreign function interface". Let's see what it takes to call foreign C library functions from Ruby.

We begin by requiring the 'ffi' library.

require 'ffi'

Next, we start a module which will serve as a namespace for PulseAudio methods and data structures.

module PulseAudio

We extend the module with the FFI::Library module. This mixes in a number of useful methods. The first one we'll use is ffi_lib, which tells FFI what dynamically-linked library to link with. The PulseAudio library is called "libpulse", and for this statement we can leave off the "lib" part.

extend FFI::Library

ffi_lib 'pulse'

After that, we set up a series of type definitions. FFI doesn't differentiate between different types of C pointers. When dealing with functions which take multiple pointer arguments, it can be confusing when each parameter is documented as simply taking a pointer. These typedefs function as aliases, which will help us document the C library functions a little more clearly.

typedef :pointer, :retval
typedef :pointer, :userdata
typedef :pointer, :pa_mainloop
typedef :pointer, :pa_mainloop_api
typedef :pointer, :pa_context

In order to list input devices, we need to be able to read the data structures which PulseAudio returns to us. PulseAudio uses a C structure called pa_source_info. Here's what it looks like:

/** Stores information about sources. Please note that this structure
 * can be extended as part of evolutionary API updates at any time in
 * any new release. */
typedef struct pa_source_info {
    const char *name;                   /**< Name of the source */
    uint32_t index;                     /**< Index of the source */
    const char *description;            /**< Description of this source */
    pa_sample_spec sample_spec;         /**< Sample spec of this source */
    pa_channel_map channel_map;         /**< Channel map */
    uint32_t owner_module;              /**< Owning module index, or PA_INVALID_INDEX */
    pa_cvolume volume;                  /**< Volume of the source */
    int mute;                           /**< Mute switch of the sink */
    uint32_t monitor_of_sink;           /**< If this is a monitor source the index of the owning sink, otherwise PA_INVALID_INDEX */
    const char *monitor_of_sink_name;   /**< Name of the owning sink, or PA_INVALID_INDEX */
    pa_usec_t latency;                  /**< Length of filled record buffer of this source. */
    const char *driver;                 /**< Driver name */
    pa_source_flags_t flags;            /**< Flags */
    pa_proplist *proplist;              /**< Property list \since 0.9.11 */
    pa_usec_t configured_latency;       /**< The latency this device has been configured to. \since 0.9.11 */
    pa_volume_t base_volume;            /**< Some kind of "base" volume that refers to unamplified/unattenuated volume in the context of the input device. \since 0.9.15 */
    pa_source_state_t state;            /**< State \since 0.9.15 */
    uint32_t n_volume_steps;            /**< Number of volume steps for sources which do not support arbitrary volumes. \since 0.9.15 */
    uint32_t card;                      /**< Card index, or PA_INVALID_INDEX. \since 0.9.15 */
    uint32_t n_ports;                   /**< Number of entries in port array \since 0.9.16 */
    pa_source_port_info** ports;        /**< Array of available ports, or NULL. Array is terminated by an entry set to NULL. The number of entries is stored in n_ports \since 0.9.16  */
    pa_source_port_info* active_port;   /**< Pointer to active port in the array, or NULL \since 0.9.16  */
    uint8_t n_formats;                  /**< Number of formats supported by the source. \since 1.0 */
    pa_format_info **formats;           /**< Array of formats supported by the source. \since 1.0 */
} pa_source_info;

We'll represent this structure as a class descended from the FFI::Struct base class. Inside, we use a layout declaration to list members of the structure. We only need a few basic bits of info for the purpose of this proof-of-concept, so we only bother listing the first few members.

class SourceInfo < FFI::Struct
  layout :name,        :string,
         :index,       :uint32,
         :description, :string    
end

Following this struct definitions come some definitions of enumerations. These mimic enums declared in the C header files for libpulse. For instance, here's the actual C definition of the pa_context_state enumeration:

/** The state of a connection context */
typedef enum pa_context_state {
    PA_CONTEXT_UNCONNECTED,    /**< The context hasn't been connected yet */
    PA_CONTEXT_CONNECTING,     /**< A connection is being established */
    PA_CONTEXT_AUTHORIZING,    /**< The client is authorizing itself to the daemon */
    PA_CONTEXT_SETTING_NAME,   /**< The client is passing its application name to the daemon */
    PA_CONTEXT_READY,          /**< The connection is established, the context is ready to execute operations */
    PA_CONTEXT_FAILED,         /**< The connection failed or was disconnected */
    PA_CONTEXT_TERMINATED      /**< The connection was terminated cleanly */
} pa_context_state_t;

Since there is no way to dynamically introspect the names and values of a C enumeration, we have to set up parallel enumerations on the Ruby side if we don't want to embed magic numbers all over our code.

enum :pa_context_flags, [:noflags,            0x0000,
                         :noautospawn,        0x0001,
                         :nofail,             0x0002]

enum :pa_context_state, [:unconnected,
                         :connecting,
                         :authorizing,
                         :setting_name,
                         :ready,
                         :failed,
                         :terminated]

Next up are some callback definitions. These declare the parameters and return values which will help FFI do the appropriate impedance-matching when the C code calls back into Ruby-land.

callback :pa_context_notify_cb_t, [:pa_context, :userdata], :void
callback :pa_source_info_cb_t, [:pointer, :pointer, :int, :pointer], :void

Last we put the real meat of the module: a series of #attach_function calls which will make C functions available as Ruby methods. Each call to #attach_function takes three arguments: the name of the function as it appears in the C library; an array of parameters which mimics the parameters declared in the C function, and a return type. In many of these we use the aliases for pointer types that we declared earlier.

attach_function :pa_mainloop_new, [], :pa_mainloop
attach_function :pa_mainloop_free, [:pa_mainloop], :void
attach_function :pa_mainloop_get_api, [:pa_mainloop], :pa_mainloop_api
attach_function :pa_mainloop_run, [:pa_mainloop, :retval], :int
attach_function :pa_mainloop_quit, [:pointer, :int], :void
attach_function :pa_context_new, [:pointer, :string], :pa_context
attach_function :pa_context_connect, [:pa_context, :string, :pa_context_flags, :pointer], :int
attach_function :pa_context_disconnect, [:pa_context], :void
attach_function :pa_context_set_state_callback, [:pa_context, :pa_context_notify_cb_t, :pointer], :void
attach_function :pa_context_get_state, [:pa_context], :pa_context_state
attach_function :pa_context_get_source_info_list, [:pa_context, :pa_source_info_cb_t, :pointer], :pointer

That's the last piece of the our PulseAudio module.

end

Now that we've declared the types and functions we need, it's time to put them to use. First, we include our new PulseAudio module into the top-level object. This will let us call the functions we declared above without a prefix.

include PulseAudio

The PulseAudio API that we'll be using is an asynchronous, callback-based one, and as such it needs an event loop to drive it. libpulse provides a few few different types of event loops out of the box; we'll be using the simplest strategy, which is called "mainloop". We initialize a mainloop object and extract a pointer to its "API" object, a structure of function pointers for different loop-related actions.

mainloop = pa_mainloop_new
api      = pa_mainloop_get_api(mainloop)

Every query into the state of the PulseAudio system must start with a "context" object. With the mainloop API pointer we acquired, we initialize a new PulseAudio context and give it a name.

context  = pa_context_new(api, "RubyTapas")

Remember, what we want to do here is query PulseAudio about available input devices. But before we can do that, we have to wait for context to be initialized within the event loop. So our next task is to set up a callback lambda. This lambda is intended to be called back when the state of our context object changes. It starts by determining whether the context is in the "ready" state yet. Here's where those enums we declared earlier pay off: instead of comparing the state with a magic number, we're able to compare it to the symbol :ready.

start_query_when_ready = ->(context, userdata) do
  state = pa_context_get_state(context)
  if state == :ready

If the context is ready for action, we prepare another callback lambda. This one, called print_audio_source is intended to print out information about an audio input device. It first checks to see if the end of the list of devices has been reached. If so, it shuts down the context and the mainloop. If not, it uses the pointer to a SourceInfo struct that it receives to print out the index and description of the found device.

print_audio_source = ->(context, source_info_ptr, eol, userdata) do
  # End of list
  if eol == 1
    pa_context_disconnect(context)
    pa_mainloop_quit(mainloop, 0)
    return
  end

  source_info = SourceInfo.new(source_info_ptr)
  puts "#{source_info[:index]} #{source_info[:description]}"      
end  

The outer context state callback takes this inner callback lambda and passes it to #pa_context_get_source_info_list. This is the function which kicks off the query for input sources. It will arrange for our print_audio_source callback to be called once for every input device, and then once more when there are non left.

    pa_context_get_source_info_list(context, print_audio_source, nil)
  end
end

It's now time to get the ball rolling on this query. We start by registering our context state callback. Then we tell our context to connect to the PulseAudio server. The last step to set everything in motion is to kick off the main loop by calling #pa_mainloop_run.

pa_context_set_state_callback(context,
                              start_query_when_ready, nil)

pa_context_connect(context, nil, :noflags, nil)
pa_mainloop_run(mainloop, nil)

The program is now blocked until the mainloop shuts down, which we trigger in our print_audio_source callback once we're out of sources. When the mainloop finishes, there's just one thing left to do: free the mainloop resources.

pa_mainloop_free(mainloop)

When we assemble all this together and run it, we get a list of audio input devices output to STDOUT:

require 'ffi'

module PulseAudio

  extend FFI::Library

  ffi_lib 'pulse'

  typedef :pointer, :retval
  typedef :pointer, :userdata
  typedef :pointer, :pa_mainloop
  typedef :pointer, :pa_mainloop_api
  typedef :pointer, :pa_context

  class SourceInfo < FFI::Struct
    layout :name,        :string,
           :index,       :uint32,
           :description, :string    
  end

  enum :pa_context_flags, [:noflags,            0x0000,
                           :noautospawn,        0x0001,
                           :nofail,             0x0002]

  enum :pa_context_state, [:unconnected,
                           :connecting,
                           :authorizing,
                           :setting_name,
                           :ready,
                           :failed,
                           :terminated]

  callback :pa_context_notify_cb_t, [:pa_context, :userdata], :void
  callback :pa_source_info_cb_t, [:pointer, :pointer, :int, :pointer], :void

  attach_function :pa_mainloop_new, [], :pa_mainloop
  attach_function :pa_mainloop_free, [:pa_mainloop], :void
  attach_function :pa_mainloop_get_api, [:pa_mainloop], :pa_mainloop_api
  attach_function :pa_mainloop_run, [:pa_mainloop, :retval], :int
  attach_function :pa_mainloop_quit, [:pointer, :int], :void
  attach_function :pa_context_new, [:pointer, :string], :pa_context
  attach_function :pa_context_connect, [:pa_context, :string, :pa_context_flags, :pointer], :int
  attach_function :pa_context_disconnect, [:pa_context], :void
  attach_function :pa_context_set_state_callback, [:pa_context, :pa_context_notify_cb_t, :pointer], :void
  attach_function :pa_context_get_state, [:pa_context], :pa_context_state
  attach_function :pa_context_get_source_info_list, [:pa_context, :pa_source_info_cb_t, :pointer], :pointer

end

include PulseAudio

mainloop = pa_mainloop_new
api      = pa_mainloop_get_api(mainloop)
context  = pa_context_new(api, "RubyTapas")

start_query_when_ready = ->(context, userdata) do
  state = pa_context_get_state(context)
  if state == :ready

  print_audio_source = ->(context, source_info_ptr, eol, userdata) do
    # End of list
    if eol == 1
      pa_context_disconnect(context)
      pa_mainloop_quit(mainloop, 0)
      return
    end

    source_info = SourceInfo.new(source_info_ptr)
    puts "#{source_info[:index]} #{source_info[:description]}"      
  end  

    pa_context_get_source_info_list(context, print_audio_source, nil)
  end
end

pa_context_set_state_callback(context,
                              start_query_when_ready, nil)

pa_context_connect(context, nil, :noflags, nil)
pa_mainloop_run(mainloop, nil)

pa_mainloop_free(mainloop)

A somewhat anti-climactic result for all that effort. But consider what we've accomplished here: we've interfaced with a C library straight from Ruby! We didn't just do basic method calls either - we arranged for the C code to call back into Ruby using lambdas, and even taught our program to work with binary data structures. And we did all of this dynamically, at runtime, without once touching a compiler.

This episode has been a whirlwind introduction to the FFI library. In future episodes we'll revisit this PulseAudio example, and evolve it from a one-off script to a proper library with more Rubyish abstractions. This is more than enough for now, though, so until next time: happy hacking!