321 lines
14 KiB
Racket
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.
|