Files
gemigreerd-racket-audio/scrbl/libao-async-ffi-racket.scrbl
2026-06-08 13:45:54 +02:00

321 lines
14 KiB
Racket

#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[#:tag "libao-async-overview"]{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[#:tag "libao-async-playback-state"]{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[#:tag "libao-async-implementation-strategy"]{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.