#lang scribble/manual @(require (for-label racket/base racket/contract "../libao-async-ffi-racket.rkt")) @title{Asynchronous libao playback in Racket} @author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]] @defmodule[racket-audio/libao-async-ffi-racket] This module implements the asynchronous libao playback backend used by @racketmodname[racket-audio]. It is a pure Racket replacement for the older C based @filepath{ao_playasync.c} backend. It exports the same Racket-level API as @filepath{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. @section{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 @racket[ao_play] call is declared with @racket[#: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. @section{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. @racketblock[ (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 @racket[ao_create_async] instead of @racket[#f]. @racketblock[ (define h (ao_create_async 16 48000 2 'little-endian "test-output.wav"))] @section{Playback handles} @defproc[(ao_version_async) exact-integer?]{ Returns the implementation version of this asynchronous backend. The current module returns @racket[3]. The value is useful for diagnostics when multiple asynchronous backends exist. } @defproc[(ao_create_async [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?)]) any/c]{ Creates an asynchronous audio handle. When @racket[wav-output-file] is @racket[#f], the default live libao driver is opened. Otherwise the libao @tt{wav} driver is opened and samples are written to the named file. The requested format is described by @racket[bits], @racket[rate], @racket[channels] and @racket[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 @racket[ao_real_output_bits_async]. The result is an asynchronous audio handle, or @racket[#f] when no suitable libao device or output file could be opened. Handles should be closed with @racket[ao_stop_async]. A finalizer is also registered as a safety net, but explicit stopping is the intended lifecycle. } @defproc[(ao_stop_async [handle any/c]) 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. } @defproc[(ao_clear_async [handle any/c]) 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. Already playing audio may still finish at the device level, depending on what libao and the operating system have accepted. This operation is used when playback is stopped, when the higher layer seeks, or when the current stream is replaced. } @defproc[(ao_pause_async [handle any/c] [paused (or/c boolean? integer?)]) 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 @racket[0] means resume and any other integer means pause. Pausing does not prevent producers from queueing additional buffers. It only prevents the worker thread from taking more data from the queue. } @section{Buffer descriptions} @defproc[(make-buffer-info [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)]) any/c]{ Constructs the format description passed to @racket[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 @racket[type] field controls whether the incoming buffer is already interleaved. Use @racket['interleaved] or the older name @racket['ao] for ordinary PCM in frame order. Use @racket['planar] or the older name @racket['flac] when each channel is stored as a separate plane. Planar input is copied to an interleaved buffer before it is queued. The @racket[sample-bits], @racket[sample-rate] and @racket[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. @racket[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. } @defproc[(make-BufferInfo_t [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)]) any/c]{ Compatibility alias for @racket[make-buffer-info]. The name is kept so code that used the older FFI module can keep constructing buffer descriptions without changing call sites. } @section{Queuing audio} @defproc[(ao_play_async [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]) any/c]{ Queues a PCM buffer for asynchronous playback. @racket[audio-buffer] may be a byte string, or an internal reusable memory object produced by this backend. External callers normally pass a byte string. @racket[buf-size] is the number of valid bytes in the buffer. The position values @racket[music-id], @racket[at-second] and @racket[music-duration] are copied into the queue element. When the worker thread starts playing that element, these values become visible through @racket[ao_is_at_music_id_async], @racket[ao_is_at_second_async] and @racket[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 @racket[ao-playback-buf-ms] and defaults to 150 milliseconds. Buffers with different @racket[music-id] values are not merged into the same output chunk. } @section{Playback state} @defproc[(ao_is_at_second_async [handle any/c]) real?]{ Returns the playback position, in seconds, associated with the queue element most recently taken by the worker thread. This value is not measured by libao; it is the @racket[at-second] value supplied by the producer of the PCM buffer. } @defproc[(ao_is_at_music_id_async [handle any/c]) 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 @racket[music-id] to @racket[ao_play_async]. } @defproc[(ao_music_duration_async [handle any/c]) real?]{ Returns the duration value associated with the queue element most recently taken by the worker thread. The value is copied from the @racket[music-duration] argument passed to @racket[ao_play_async]. } @defproc[(ao_bufsize_async [handle any/c]) exact-nonnegative-integer?]{ 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. It is a backend queue size, not the size of the operating-system or hardware audio buffer. } @defproc[(ao_sample_queue_len [handle any/c]) exact-nonnegative-integer?]{ 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 @racket[ao_play_async]. } @defproc[(ao_reuse_buf_len [handle any/c]) exact-nonnegative-integer?]{ 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. } @section{Volume and output format} @defproc[(ao_set_volume_async [handle any/c] [percentage real?]) any/c]{ Sets the software volume. @racket[100.0] is normal volume, @racket[50.0] is half volume and values above @racket[100.0] amplify the samples. The implementation stores the setting as an integer scaled by 100, so normal volume is represented internally as @racket[10000]. Values very close to @racket[100.0] are normalized to exactly @racket[10000] to avoid unnecessary sample processing. 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. } @defproc[(ao_volume_async [handle any/c]) real?]{ Returns the current software volume percentage. A result of @racket[100.0] means normal volume. } @defproc[(ao_real_output_bits_async [handle any/c]) exact-nonnegative-integer?]{ 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. In that case, @racket[ao_play_async] converts the incoming samples before playback. } @section{Playback buffer tuning} @defproc[(ao-playback-buf-ms) exact-nonnegative-integer?]{ Returns the target size, in milliseconds, of the playback chunks that the backend sends to libao. The default is @racket[150]. } @defproc[(ao-set-playback-buf-ms! [ms exact-nonnegative-integer?]) void?]{ Sets the target playback chunk size in milliseconds. 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. } @section{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 @racket[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 @racket['atomic-interior], and calls libao. The foreign @racket[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 @racket[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. @section{Compatibility notes} The exported names are kept compatible with the old @filepath{libao-async-ffi.rkt} layer. In particular, @racket[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.