Asynchronous libao playback in Racket
1 Basic example
2 Playback handles
ao_  version_  async
ao_  create_  async
ao_  stop_  async
ao_  clear_  async
ao_  pause_  async
3 Buffer descriptions
make-buffer-info
make-Buffer  Info_  t
4 Queuing audio
ao_  play_  async
5 Playback state
ao_  is_  at_  second_  async
ao_  is_  at_  music_  id_  async
ao_  music_  duration_  async
ao_  bufsize_  async
ao_  sample_  queue_  len
ao_  reuse_  buf_  len
6 Volume and output format
ao_  set_  volume_  async
ao_  volume_  async
ao_  real_  output_  bits_  async
7 Implementation strategy
8 Compatibility notes
9.1

Asynchronous libao playback in Racket🔗ℹ

Hans Dijkema <hans@dijkewijk.nl>

 (require (file "../libao-async-ffi-racket.rkt"))
  package: base

This module is a pure Racket replacement for the older C based "ao_playasync.c" backend. It exports the same Racket level API as "libao-async-ffi.rkt" and still sends PCM to Xiph libao, but the queue, worker thread, buffering, format conversion and volume scaling are all implemented in Racket.

The module is meant to sit below the higher level sound player. Client code creates one asynchronous audio handle, queues PCM buffers with playback position information, and lets a Racket worker thread feed libao. The foreign ao_play call is declared with #:blocking? so that a blocking write to the audio device does not unnecessarily hold up other Racket threads.

1 Basic example🔗ℹ

The normal live playback path opens the default libao driver. The output bit format may be lower than the requested bit format when libao cannot open the device with the requested precision.

(define h
  (ao_create_async 32 44100 2 'native-endian #f))
 
(define info
  (make-buffer-info 'interleaved 32 44100 2 'native-endian))
 
(when h
  (ao_play_async h 1 0.0 180.0 (bytes-length pcm) pcm info)
  (ao_stop_async h))

To write to a WAV file instead of the default live device, pass a path string as the last argument to ao_create_async instead of #f .

(define h
  (ao_create_async 16 48000 2 'little-endian "test-output.wav"))

2 Playback handles🔗ℹ

procedure

(ao_version_async)  exact-integer?

Returns the implementation version of this asynchronous backend. The current module returns 3.

procedure

(ao_create_async bits    
  rate    
  channels    
  byte-format    
  wav-output-file)  any/c
  bits : exact-integer?
  rate : exact-integer?
  channels : exact-integer?
  byte-format : (or/c 'little-endian 'big-endian 'native-endian)
  wav-output-file : (or/c #f path-string?)
Creates an asynchronous audio handle. When wav-output-file is #f the default live libao driver is opened. Otherwise the libao wav driver is opened and the samples are written to the named file.

The requested format is described by bits, rate, channels and byte-format. If the requested number of bits cannot be opened and the request is wider than 24 or 16 bits, the module tries 24-bit and then 16-bit output. The resulting device precision can be queried with ao_real_output_bits_async.

The result is an asynchronous audio handle, or #f when no device or output file could be opened. Handles should be closed with ao_stop_async. A finalizer is also registered as a safety net, but explicit stopping is the intended lifecycle.

procedure

(ao_stop_async handle)  any/c

  handle : any/c
Stops playback, clears queued data, wakes the worker thread when it is paused, queues a stop command, waits for the worker thread to finish, closes the libao device and marks the handle as invalid. The function returns the handle. Calling the other playback functions on an invalid handle is an error.

procedure

(ao_clear_async handle)  any/c

  handle : any/c
Clears queued audio data without closing the device. The buffer that is being assembled for the next queued play item is also discarded. This is the operation used when playback is stopped or when the higher layer seeks.

procedure

(ao_pause_async handle paused)  any/c

  handle : any/c
  paused : any/c
Pauses or resumes the worker thread. A boolean value is used directly. An integer value is accepted for compatibility with the old FFI layer, where 0 means resume and any other integer means pause.

3 Buffer descriptions🔗ℹ

procedure

(make-buffer-info type    
  sample-bits    
  sample-rate    
  channels    
  endianness)  any/c
  type : symbol?
  sample-bits : exact-integer?
  sample-rate : exact-integer?
  channels : exact-integer?
  endianness : (or/c 'little-endian 'big-endian 'native-endian)
Constructs the format description passed to ao_play_async. Only the constructor is exported. The struct predicate and accessors remain private to this module, because the value is primarily a compatibility object for the audio backend.

The type field controls whether the incoming buffer is already interleaved. Use 'interleaved or the older name 'ao for ordinary PCM in frame order. Use 'planar or the older name 'flac when each channel is stored as a separate plane. Planar input is copied to an interleaved buffer before it is queued.

Samples are treated as signed integer PCM. sample-bits must describe whole bytes, such as 16, 24 or 32 bits. The conversion code uses the supplied endianness when reading input samples and when writing converted output samples.

procedure

(make-BufferInfo_t type    
  sample-bits    
  sample-rate    
  channels    
  endianness)  any/c
  type : symbol?
  sample-bits : exact-integer?
  sample-rate : exact-integer?
  channels : exact-integer?
  endianness : (or/c 'little-endian 'big-endian 'native-endian)
Compatibility alias for make-buffer-info. The name is kept so code that used the older FFI module can keep constructing buffer descriptions without changing call sites.

4 Queuing audio🔗ℹ

procedure

(ao_play_async handle    
  music-id    
  at-second    
  music-duration    
  buf-size    
  audio-buffer    
  buffer-info)  any/c
  handle : any/c
  music-id : any/c
  at-second : real?
  music-duration : real?
  buf-size : exact-integer?
  audio-buffer : any/c
  buffer-info : any/c
Queues a PCM buffer for asynchronous playback. audio-buffer may be a byte string, or an internal reusable memory object produced by this backend. External callers normally pass a byte string. buf-size is the number of valid bytes in the buffer.

The position values at-second, music-duration and music-id are copied into the queue element. When the worker thread starts playing that element, these values become visible through ao_is_at_second_async, ao_music_duration_async and ao_is_at_music_id_async.

If the input buffer is planar, it is first converted to interleaved PCM. If the input sample width or endianness differs from the opened output device, the sample data is converted before queueing. Small input chunks are collected into larger queue elements of roughly 250 milliseconds, unless the music id changes or the current assembled buffer is full.

5 Playback state🔗ℹ

procedure

(ao_is_at_second_async handle)  real?

  handle : any/c
Returns the playback position, in seconds, associated with the queue element most recently taken by the worker thread. This is not measured by libao; it is the position reported by the producer of the PCM buffer.

procedure

(ao_is_at_music_id_async handle)  any/c

  handle : any/c
Returns the music id associated with the queue element most recently taken by the worker thread. The value is whatever was passed as music-id to ao_play_async.

procedure

(ao_music_duration_async handle)  real?

  handle : any/c
Returns the duration value associated with the queue element most recently taken by the worker thread. The value is copied from the music-duration argument passed to ao_play_async.

procedure

(ao_bufsize_async handle)  exact-integer?

  handle : any/c
Returns the number of audio bytes currently counted as buffered by the async queue administration. The value is updated when buffers are queued, combined, taken by the worker thread or cleared.

procedure

(ao_sample_queue_len handle)  exact-integer?

  handle : any/c
Returns the number of queue elements currently counted by the async queue administration. Since the module combines small input buffers into larger playback chunks, this is not the same as the number of calls made to ao_play_async.

procedure

(ao_reuse_buf_len handle)  exact-integer?

  handle : any/c
Returns the number of reusable byte buffers currently kept by the backend. This is an implementation diagnostic. It is useful for checking whether the reuse pool is being exercised, but it is not an audio latency measurement.

6 Volume and output format🔗ℹ

procedure

(ao_set_volume_async handle percentage)  any/c

  handle : any/c
  percentage : real?
Sets the software volume. 100.0 is normal volume, 50.0 is half volume and values above 100.0 amplify the samples. The implementation stores the setting as an integer scaled by 100, so normal volume is represented internally as 10000.

Volume is applied by the worker thread immediately before copying the playback chunk to the foreign buffer passed to libao. Scaled samples are clipped to the signed range of the opened output sample width.

procedure

(ao_volume_async handle)  real?

  handle : any/c
Returns the current software volume percentage. A result of 100.0 means normal volume.

procedure

(ao_real_output_bits_async handle)  exact-integer?

  handle : any/c
Returns the actual number of bits per sample used by the opened libao device. This may be lower than the requested width if device opening fell back from 32-bit to 24-bit or 16-bit output.

7 Implementation strategy🔗ℹ

The module keeps libao as the only native audio backend, but moves the async queue and playback thread from C to Racket. It initializes libao lazily when the first handle or temporary driver query is opened. A small reference count is used so ao_shutdown is called when the last opened handle is closed. A custodian and exit finalizer call shutdown as a last resort.

The worker thread is created with its own thread pool. It waits for queue elements, observes the pause lock, applies volume when necessary, copies the chunk into foreign memory allocated as 'atomic-interior, and calls libao. The foreign ao_play binding is marked as blocking. The combination matters: libao may retain the pointer for the duration of the call, and a blocking foreign call should not receive movable Racket byte storage directly.

Small decoder buffers are combined before reaching libao. The target chunk size is controlled by the internal ao-buf-ms value, currently 250 milliseconds. The buffer reuse pool keeps allocated byte strings around for future chunks, reducing allocation churn in long playback sessions.

The conversion path is intentionally narrow. Planar input is converted to interleaved PCM. Sample width conversion is done with arithmetic shifts, and endianness conversion is handled through Racket byte operations. This backend therefore expects integer PCM with byte-aligned sample widths.

8 Compatibility notes🔗ℹ

The exported names are kept compatible with the old "libao-async-ffi.rkt" layer. In particular, make-BufferInfo_t remains available even though the actual value is a Racket struct, not a C struct. The higher layers can therefore select this module as an implementation backend without changing the playback API.

The queue state functions report the backend’s own administration. They do not query libao for device latency, and they do not know how many samples are already buffered by the operating system or the audio driver.