Files
gemigreerd-racket-audio/scrbl/libao-async-ffi-racket.scrbl
T
2026-05-11 20:03:28 +02:00

265 lines
11 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[(file "../libao-async-ffi-racket.rkt")]
This module 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 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
@racket[ao_play] call is declared with @racket[#:blocking?] so that a blocking
write to the audio device does not unnecessarily hold up other Racket threads.
@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_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]
@elem{.}
@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]@elem{.}
}
@defproc[(ao_create_async
[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?)])
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 the samples are written to the named file.
The requested format is described by @racket[bits]@elem{,} @racket[rate]@elem{,}
@racket[channels] and @racket[byte-format]@elem{.} 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]@elem{.}
The result is an asynchronous audio handle, or @racket[#f] when no device or
output file could be opened. Handles should be closed with
@racket[ao_stop_async]@elem{.} 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. This is the
operation used when playback is stopped or when the higher layer seeks.
}
@defproc[(ao_pause_async [handle any/c]
[paused any/c]) 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.
}
@section{Buffer descriptions}
@defproc[(make-buffer-info
[type symbol?]
[sample-bits exact-integer?]
[sample-rate exact-integer?]
[channels exact-integer?]
[endianness (or/c 'little-endian 'big-endian 'native-endian)])
any/c]{
Constructs the format description passed to @racket[ao_play_async]@elem{.}
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.
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-integer?]
[sample-rate exact-integer?]
[channels exact-integer?]
[endianness (or/c 'little-endian 'big-endian 'native-endian)])
any/c]{
Compatibility alias for @racket[make-buffer-info]@elem{.} 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-integer?]
[audio-buffer 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[at-second]@elem{,} @racket[music-duration] and
@racket[music-id] are copied into the queue element. When the worker thread
starts playing that element, these values become visible through
@racket[ao_is_at_second_async]@elem{,} @racket[ao_music_duration_async] and
@racket[ao_is_at_music_id_async]@elem{.}
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.
}
@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 is not measured by libao; it is
the position reported 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]@elem{.}
}
@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]@elem{.}
}
@defproc[(ao_bufsize_async [handle any/c]) exact-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.
}
@defproc[(ao_sample_queue_len [handle any/c]) exact-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]@elem{.}
}
@defproc[(ao_reuse_buf_len [handle any/c]) exact-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 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.
}
@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-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.
}
@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 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 @racket['atomic-interior]@elem{,} 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 the internal @racket[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.
@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.