audio-play! returns the file id.

The file id is now a randomized number
This commit is contained in:
2026-05-17 20:19:06 +02:00
parent f706d4e8e6
commit 65ca59bef8
7 changed files with 170 additions and 499 deletions
+143 -88
View File
@@ -7,19 +7,39 @@
@title{Asynchronous libao playback in Racket}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@defmodule[(file "../libao-async-ffi-racket.rkt")]
@defmodule[racket-audio/libao-async-ffi-racket]
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
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 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.
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}
@@ -36,11 +56,13 @@ device with the requested precision.
(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]
@elem{.}
as the last argument to @racket[ao_create_async] instead of @racket[#f].
@racketblock[
(define h
@@ -50,30 +72,31 @@ as the last argument to @racket[ao_create_async] instead of @racket[#f]
@defproc[(ao_version_async) exact-integer?]{
Returns the implementation version of this asynchronous backend. The current
module returns @racket[3]@elem{.}
module returns @racket[3]. The value is useful for diagnostics when multiple
asynchronous backends exist.
}
@defproc[(ao_create_async
[bits exact-integer?]
[rate exact-integer?]
[channels exact-integer?]
[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 the samples are written to the named file.
@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]@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 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 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.
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]{
@@ -85,30 +108,36 @@ 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.
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 any/c]) 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-integer?]
[sample-rate exact-integer?]
[channels exact-integer?]
[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]@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.
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
@@ -116,20 +145,23 @@ 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.
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-integer?]
[sample-rate exact-integer?]
[channels exact-integer?]
[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]@elem{.} The name is kept so
code that used the older FFI module can keep constructing buffer descriptions
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.
}
@@ -140,8 +172,8 @@ without changing call sites.
[music-id any/c]
[at-second real?]
[music-duration real?]
[buf-size exact-integer?]
[audio-buffer any/c]
[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
@@ -149,66 +181,71 @@ 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{.}
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 of roughly 250 milliseconds, unless the music id
changes or the current assembled buffer is full.
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 is not measured by libao; it is
the position reported by the producer of the PCM buffer.
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]@elem{.}
@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]@elem{.}
@racket[music-duration] argument passed to @racket[ao_play_async].
}
@defproc[(ao_bufsize_async [handle any/c]) exact-integer?]{
@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.
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-integer?]{
@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]@elem{.}
@racket[ao_play_async].
}
@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.
@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 10000.
[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
@@ -220,10 +257,29 @@ 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?]{
@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.
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}
@@ -231,21 +287,20 @@ This may be lower than the requested width if device opening fell back from
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.
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.
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 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.
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
@@ -260,6 +315,6 @@ The exported names are kept compatible with the old
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.
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.