Asynchronous libao playback in Racket
| (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?
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?)
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.
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)
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)
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
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_bufsize_async handle) → exact-integer?
handle : any/c
procedure
(ao_sample_queue_len handle) → exact-integer?
handle : any/c
procedure
(ao_reuse_buf_len handle) → exact-integer?
handle : any/c
6 Volume and output format
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_real_output_bits_async handle) → exact-integer?
handle : any/c
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.