8 Asynchronous libao playback in Racket
| (require racket-audio/libao-async-ffi-racket) | |
| package: racket-audio | |
This module implements the asynchronous libao playback backend used by racket-audio. It 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 intended as a low-level backend below the higher-level sound player. Client code creates one asynchronous audio handle, queues decoded PCM buffers together with playback position information, and lets a Racket worker thread feed libao. Higher-level player code should normally use the public player interface instead of calling this module directly.
8.1 Overview
The backend accepts decoded PCM buffers, converts them when needed, groups small buffers into larger playback chunks, and sends those chunks to libao from a dedicated Racket worker thread. The foreign ao_play call is declared with #:blocking?, so a blocking write to the audio device does not unnecessarily hold up other Racket threads.
Incoming buffers may be interleaved or planar. Planar buffers, such as those commonly produced by a FLAC decoder, are converted to interleaved PCM before playback. If the requested sample width cannot be opened on the selected audio device, the backend tries lower-width output formats and converts samples before they are sent to libao.
The backend also maintains playback position metadata. Each queued buffer is tagged with a music id, a current playback position and a duration. These values are used by the higher-level player to report which track the output thread has reached.
8.2 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_set_volume_async h 80.0) (ao_pause_async h #t) (ao_pause_async h #f) (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"))
8.3 Playback handles
procedure
procedure
(ao_create_async bits rate channels byte-format wav-output-file) → any/c bits : exact-positive-integer? rate : exact-positive-integer? channels : exact-positive-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 suitable libao 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
procedure
(ao_clear_async handle) → any/c
handle : any/c
Pausing does not prevent producers from queueing additional buffers. It only prevents the worker thread from taking more data from the queue.
8.4 Buffer descriptions
procedure
(make-buffer-info type sample-bits sample-rate channels endianness) → any/c type : symbol? sample-bits : exact-positive-integer? sample-rate : exact-positive-integer? channels : exact-positive-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.
The sample-bits, sample-rate and channels fields describe the format of the supplied buffer, not necessarily the format that will eventually be accepted by the device. 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-positive-integer? sample-rate : exact-positive-integer? channels : exact-positive-integer? endianness : (or/c 'little-endian 'big-endian 'native-endian)
8.5 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-nonnegative-integer? audio-buffer : (or/c bytes? any/c) buffer-info : any/c
The position values music-id, at-second and music-duration are copied into the queue element. When the worker thread starts playing that element, these values become visible through ao_is_at_music_id_async, ao_is_at_second_async and ao_music_duration_async. They do not affect sample conversion.
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. The target chunk size is controlled by ao-playback-buf-ms and defaults to 150 milliseconds. Buffers with different music-id values are not merged into the same output chunk.
8.6 Playback state
procedure
(ao_is_at_second_async handle) → real?
handle : any/c
procedure
(ao_is_at_music_id_async handle) → any/c
handle : any/c
procedure
(ao_music_duration_async handle) → real?
handle : any/c
procedure
(ao_bufsize_async handle) → exact-nonnegative-integer?
handle : any/c
procedure
(ao_sample_queue_len handle) → exact-nonnegative-integer?
handle : any/c
procedure
(ao_reuse_buf_len handle) → exact-nonnegative-integer?
handle : any/c
8.7 Volume and output format
procedure
(ao_set_volume_async handle percentage) → any/c
handle : any/c percentage : real?
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
procedure
(ao_real_output_bits_async handle) → exact-nonnegative-integer?
handle : any/c
8.8 Playback buffer tuning
procedure
procedure
(ao-set-playback-buf-ms! ms) → void?
ms : exact-nonnegative-integer?
Larger values reduce the number of calls to libao and may help prevent audible glitches when decoders produce many small buffers. Smaller values reduce latency but increase scheduling pressure on the Racket worker thread and on the audio backend.
8.9 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 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 ao-playback-buf-ms. 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.10 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.