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
+9 -4
View File
@@ -10,6 +10,8 @@
(provide placed-player)
(define get-current-seconds current-seconds)
(define (eq-seconds? s1 s2)
(let ((s1* (inexact->exact (round s1)))
(s2* (inexact->exact (round s2))))
@@ -317,7 +319,9 @@
(dbg-sound "oke done")
)
(set! current-file-id (+ current-file-id 1))
;(set! current-file-id (+ current-file-id 1))
(set! current-file-id (+ (* (get-current-seconds) 10000) (random 1000)))
(let ((f (build-path file)))
(set! files-playing (cons
(cons current-file-id f)
@@ -328,7 +332,8 @@
(when (eq? player-state 'stopped)
(set! player-state 'playing))
(audio-read-worker ao-dec current-file-id))
(audio-read-worker ao-dec current-file-id)
current-file-id)
(define (pause paused)
(when (or (eq? player-state 'paused)
@@ -435,8 +440,8 @@
((eq? cmd 'open)
(do-rpc
(let ((file (cadr data)))
(start file)
'(ok))))
(let ((id (start file)))
(list (list 'ok id))))))
((eq? cmd 'seek)
(do-rpc
(let ((percentage (cadr data)))
+7 -2
View File
@@ -143,6 +143,8 @@
(set-audio-play-state! handle (evt-data e))
(cb-state* (evt-data e)))
((is-event? e 'audio-done) (cb-eof*))
((is-event? e 'exception)
(err-sound "audio-player: exception event: ~a" e))
(else (warn-sound "audio-player: unknown event ~a" e))
)
(loop))
@@ -175,8 +177,11 @@
)
(define/contract (audio-play! handle audio-file)
(-> audio-play? path-string? symbol?)
((audio-play-rpc handle) 'open audio-file))
(-> audio-play? path-string? number?)
(let ((result ((audio-play-rpc handle) 'open audio-file)))
(when (eq? result 'error)
(error "Got an error from the placed audio player"))
(cadr result)))
(define/contract (audio-pause! handle paused)
(-> audio-play? boolean? symbol?)
+3 -3
View File
@@ -9,7 +9,7 @@
"tests.rkt"
)
(define place-mode #t)
(define place-mode #f)
(define run-queue #f)
(define (set-test a)
@@ -60,13 +60,13 @@
(if (null? play-queue)
(audio-quit! h)
(begin
(audio-play! h (car play-queue))
(dbg-sound "audio-play! -> ~a" (audio-play! h (car play-queue)))
(set! play-queue (cdr play-queue))
)
))
(when (eq? run-queue 'once)
(set! run-queue #f)
(audio-play! h test-file3))
(dbg-sound "audio-play! -> ~a" (audio-play! h (car play-queue))))
)
(define h (make-audio-player audio-player-state
+4 -2
View File
@@ -72,7 +72,8 @@ The important command-level behaviour is:
@item{@racket['init] installs the reply and event channels and moves the
player into the initialized command loop.}
@item{@racket['open] starts a decoder and a read worker. If another worker is
still feeding audio, it is first interrupted and joined.}
still feeding audio, it is first interrupted and joined. Returns
a @tt{music id} for the given file.}
@item{@racket['pause] only changes @racket[player-state]. The worker observes
that state and applies @racket[ao-pause] to the output side.}
@item{@racket['seek] clears the async output queue and seeks the decoder, but
@@ -96,7 +97,8 @@ an RPC-style command is sent on the reply channel installed by
@item{@racket[(list 'init ch-out ch-evt)] installs @racket[ch-out] and
@racket[ch-evt], and replies with @racket['(initialized)].}
@item{@racket[(list 'open file)] opens @racket[file], starts the decoder
worker, and replies with @racket['(ok)].}
worker, and replies with @racket[(list ok music-id)], where
@tt{music-id} is the given music id to the file to play.}
@item{@racket[(list 'pause paused?)] sets @racket[player-state] to
@racket['paused] or @racket['playing] when the player is already active,
and replies with @racket['(ok)].}
+4 -2
View File
@@ -76,10 +76,12 @@ is invalidated and @racket[audio-play?] returns @racket[#f].}
@defproc[(audio-play! [player audio-play?]
[audio-file path-string?])
symbol?]{
number?]{
Starts playback of @racket[audio-file]. The file is opened by the worker side,
a decoder is selected, and audio feeding begins asynchronously. In normal use
the return value is @racket['ok].
the return value is @racket[music-id], where @tt{music-id} is the
given @tt{id}, a @tt{number? >= 1} to the file to play, which will be used when reporting in
callbacks about e.g. state.
Calling @racket[audio-play!] while another file is still active replaces the
current stream. The worker side interrupts the old decoder, clears the output
-398
View File
@@ -1,398 +0,0 @@
#lang scribble/manual
@(require (for-label racket/base
;racket/contract
racket/path
ffi/unsafe
let-assert
early-return
"../ffmpeg-definitions.rkt"
"../private/cstruct-helper.rkt"))
@title[#:tag "ffmpeg-definitions"]{FFmpeg Decoder Definitions}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@defmodule[ffmpeg-definitions]
This module provides the direct FFmpeg-backed decoder layer used by the audio
pipeline. It is deliberately small and stateful. A caller creates one decoder
instance, opens one file on it, queries the selected audio stream, repeatedly
asks for the next PCM block, and closes the instance again.
The module does not expose FFmpeg metadata. It only exposes the information
needed for playback: stream count, sample rate, channel count, duration,
bitrate, decoded PCM data, and sample positions. The output format is fixed:
interleaved signed 32-bit PCM, four bytes per sample, using FFmpeg's
@tt{AV_SAMPLE_FMT_S32} sample format.
The FFmpeg libraries are loaded when the module is required. The module checks
that the runtime FFmpeg major versions are in the supported range configured by
the implementation. This binding targets the FFmpeg library major versions
used by FFmpeg 6, 7, and 8: @tt{libavutil} 58 to 60, @tt{libavcodec} 60 to 62,
@tt{libavformat} 60 to 62, and @tt{libswresample} 4 to 6. Unsupported runtime
versions fail early, before a decoder instance is used.
On Windows, the private library loader may download the bundled sound-library
set into Racket's add-on directory before the FFI libraries are opened. On
Unix-like systems, the FFmpeg libraries are expected to be installed by the
operating system or platform package manager and to be reachable by Racket's
FFI library search path.
@section{Layering}
This module is the low-level Racket FFI layer. It is normally wrapped by
@filepath{ffmpeg-ffi.rkt} and then by @filepath{ffmpeg-decoder.rkt}@elem{.}
The first wrapper adapts this module to the command protocol used by the audio
decoder frontend. The second wrapper exposes the callback-oriented decoder
interface used by the rest of the playback pipeline.
The distinction matters for buffer lifetime. At this level,
@racket[fmpg-buffer] returns the current buffer owned by the decoder instance.
The adapter in @filepath{ffmpeg-ffi.rkt} copies that buffer before passing it to
@filepath{ffmpeg-decoder.rkt}@elem{.} Code that uses this module directly must
copy the buffer itself when the bytes must survive the next decoder operation.
@section{Implementation strategy}
This module talks directly to the FFmpeg shared libraries through Racket's FFI.
There is no C shim that hides FFmpeg's structs or normalizes their layout. The
price of that choice is that the Racket side must know enough of the relevant C
struct layouts to read the fields used by the decoder. The benefit is that the
binding remains a Racket module with direct access to the platform FFmpeg
libraries.
@subsection{Versioned C struct layouts}
The module defines only partial FFmpeg structs. A partial definition includes
the fields that are actually read by this decoder and enough preceding fields to
compute their offsets. Fields that are not needed are represented only by their
C type, or by a repetition count such as @racket[(6 _int)]@elem{.} Tail fields
after the last required member are not described.
The helper module @filepath{private/cstruct-helper.rkt} provides
@racket[make-offsets] and @racket[def-cstruct]@elem{.} The
@racket[make-offsets] form computes offsets for a sequence of C field types,
while @racket[def-cstruct] expands to a @racket[define-cstruct] form whose
public fields are placed at those explicit offsets. This keeps the actual
accessors small while still accounting for skipped fields in the C layout.
The right layout is selected when the module is required, after the runtime
FFmpeg major versions have been read from the libraries. For the supported
range, @racket[_AVCodecParameters] uses one layout for
@tt{libavcodec} major version 60 and another for major versions 61 and 62.
Likewise, @racket[_AVFrame] uses one layout for @tt{libavutil} major version
58 and another for major versions 59 and 60. The other partial structs used by
this module are defined with a single layout across the supported versions.
This is why the version check is performed before normal decoder use. The
accessors are correct only for the FFmpeg major-version ranges for which the
partial layouts were written. If a future FFmpeg major release changes a
layout before one of the fields used here, the version range should be extended
only after the affected partial definitions have been checked.
@subsection{Sequential failure handling}
Most FFmpeg calls report ordinary failure through C-style return values or null
pointers. The implementation treats those results as normal control flow, not
as exceptional Racket failures. The @racket[let/assert] form is used for this
pattern. It behaves like a sequential binding form: each binding can be checked
immediately, and a failed check returns the specified failure value for the
whole form.
That style is used for setup paths such as opening a file, selecting stream
information, allocating the codec context, and initializing the resampler. It
keeps the success path linear while still giving each FFmpeg return value or
pointer a local check. Predicates such as @tt{a-!nullptr?}@elem{,}
@tt{a-nullptr?}@elem{,} @tt{a-true?}@elem{,} and @tt{a->=?} express the usual
FFmpeg checks directly next to the binding that produced the value.
For loops where decoding must stop immediately from a nested position, the
module uses @racket[define/return] from @racketmodname[define-return]@elem{.}
This gives functions such as @racket[fmpg-decode-next!] and the internal
resampler drain routine an explicit early-return continuation without using
exceptions for normal FFmpeg outcomes. The two helpers are implementation
dependencies; they are not re-exported by this module.
@section{Decoder instances}
A decoder instance is an opaque value returned by @racket[fmpg-init]@elem{.}
Its structure type and predicate are not exported. Pass the value back to the
functions in this module and do not inspect it directly. The contracts below
therefore use @racket[any/c] for the instance argument. Operationally, that
argument must be a value returned by @racket[fmpg-init]@elem{.}
The instance owns native FFmpeg resources: a format context, a codec context,
an audio frame, a resampler, and the Racket byte string used for the current
PCM block. Finalizers are installed as a last line of defence, but callers
should still call @racket[fmpg-close!] explicitly when playback stops or when
the file is no longer needed. Explicit close keeps the lifetime of native
resources predictable.
@defproc[(fmpg-init) any/c]{
Creates a new decoder instance. The result is an opaque instance value, or
@racket[#f] if the instance could not be created.
Creating the instance does not open a file. Use @racket[fmpg-open-file!]
before querying stream information or decoding audio.
}
@defproc[(fmpg-open-file! [instance any/c]
[filename (or/c path? string?)])
(integer-in 0 1)]{
Opens @racket[filename] on @racket[instance]@elem{,} reads the stream
information, selects the best audio stream, initializes the codec context, and
initializes the resampler.
The function returns @racket[1] on success and @racket[0] on failure. On
failure, partially initialized native state is closed again.
An instance can only have one file open. Close it with @racket[fmpg-close!]
before opening another file on the same instance. A non-string, non-path
filename is treated as an open failure and returns @racket[0]@elem{.}
}
@defproc[(fmpg-close! [instance any/c]) void?]{
Closes @racket[instance] if it is open and releases the native FFmpeg resources
owned by the instance. The stored audio information is reset. Calling this
function with @racket[#f] or with an already closed instance is harmless.
}
@defproc[(fmpg-is-open [instance any/c]) (integer-in 0 1)]{
Returns @racket[1] when @racket[instance] is ready for decoding and
@racket[0] otherwise. An instance is ready only after a file has been opened,
a usable audio stream has been selected, and the decoder and resampler have
been initialized.
}
@section{Audio stream information}
The decoder selects one audio stream for playback using FFmpeg's best-stream
selection. The stream count reports how many audio streams were found in the
container, but decoding is performed only for the selected stream.
The term @italic{sample} in this module means a sample frame: one time step in
the audio stream, across all channels. For stereo 32-bit output, one sample
frame therefore occupies @racket[(* 2 4)] bytes in the returned PCM buffer.
@defproc[(fmpg-audio-stream-count [instance any/c])
exact-nonnegative-integer?]{
Returns the number of audio streams in the open container. If the instance is
not open, the result is @racket[0]@elem{.}
}
@defproc[(fmpg-audio-sample-rate [instance any/c])
exact-nonnegative-integer?]{
Returns the selected audio stream's sample rate. If the instance is not ready,
the result is @racket[0]@elem{.}
}
@defproc[(fmpg-audio-channels [instance any/c])
exact-nonnegative-integer?]{
Returns the selected audio stream's channel count. If the instance is not
ready, the result is @racket[0]@elem{.}
}
@defproc[(fmpg-audio-bits-per-sample [instance any/c])
exact-positive-integer?]{
Returns the fixed output sample width in bits. The current output format is
32-bit signed PCM, so this function returns @racket[32]@elem{.} The value is
independent of the input file's original sample format and does not depend on
the instance state.
}
@defproc[(fmpg-audio-bytes-per-sample [instance any/c])
exact-positive-integer?]{
Returns the fixed output sample width in bytes. The current output format is
32-bit signed PCM, so this function returns @racket[4]@elem{.} The value is
independent of the input file's original sample format and does not depend on
the instance state.
}
@defproc[(fmpg-duration-ms [instance any/c]) exact-integer?]{
Returns the duration of the selected audio stream in milliseconds. If the
stream duration is not available, the container duration is used as a fallback.
If no duration can be determined, or when the instance is not ready, the result
is @racket[-1]@elem{.}
}
@defproc[(fmpg-duration-samples [instance any/c]) exact-integer?]{
Returns the duration of the selected audio stream in sample frames. If the
stream duration is not available, the container duration is used as a fallback.
If no duration can be determined, or when the instance is not ready, the result
is @racket[-1]@elem{.}
}
@defproc[(fmpg-file-bitrate [instance any/c]) exact-integer?]{
Returns the container bitrate in bits per second. If the bitrate is
unavailable or if the instance is not open, the result is @racket[-1]@elem{.}
}
@section{Decoding}
Decoding is block oriented. Each call to @racket[fmpg-decode-next!] clears the
previous PCM block and attempts to produce the next decoded block for the
selected audio stream. When the call returns @racket[1]@elem{,} the block can
be read with @racket[fmpg-buffer] and described with the buffer query
functions.
@defproc[(fmpg-decode-next! [instance any/c]) (integer-in 0 1)]{
Decodes until a block of PCM output is available or no more output can be
produced. The function returns @racket[1] when @racket[fmpg-buffer] contains a
non-empty PCM block. It returns @racket[0] when the instance is not ready, when
end of stream has been reached, or when FFmpeg reports an unrecoverable decode
error.
The function does not distinguish end of stream from a decode failure. The
intended playback loop treats @racket[0] as no further PCM block available for
this decoder instance.
Internally, decoding receives all currently available frames, reads packets for
the selected audio stream, sends those packets to the codec, converts decoded
frames through @tt{libswresample}@elem{,} and drains the resampler at end of
stream. Non-selected packets are skipped.
}
@defproc[(fmpg-seek-ms! [instance any/c]
[target-pos-ms exact-nonnegative-integer?])
(integer-in 0 1)]{
Seeks the selected audio stream to @racket[target-pos-ms] milliseconds and
resets the decoder and resampler state. The function returns @racket[1] on
success and @racket[0] on failure.
Seeking uses FFmpeg's backward seek flag. After the seek, decoded audio before
the requested target sample is discarded so the next buffer starts at, or as
close as FFmpeg can provide to, the requested position.
}
@section{Decoded buffers}
The PCM buffer belongs to the decoder instance. It is replaced by the next
call to @racket[fmpg-decode-next!]@elem{,} @racket[fmpg-seek-ms!]@elem{,} or
@racket[fmpg-close!]@elem{.} Treat the returned byte string as read-only.
Copy it if it must outlive the next decoder operation or if another component
may mutate it.
@defproc[(fmpg-buffer [instance any/c]) (or/c bytes? #f)]{
Returns the current decoded PCM block as a byte string, or @racket[#f] when no
PCM block is available.
The byte string contains interleaved signed 32-bit samples. Its logical frame
count is available as the difference between @racket[fmpg-buffer-end-sample]
and @racket[fmpg-buffer-start-sample]@elem{.} Its byte size is also available
through @racket[fmpg-buffer-size]@elem{.}
}
@defproc[(fmpg-buffer-size [instance any/c]) exact-nonnegative-integer?]{
Returns the number of valid bytes in the current PCM buffer. If no decoder
state is available, or if the size would not fit in the internal integer range,
the function returns @racket[0]@elem{.}
}
@defproc[(fmpg-buffer-start-sample [instance any/c])
exact-nonnegative-integer?]{
Returns the first sample frame represented by the current PCM buffer. If no
decoder state is available, the result is @racket[0]@elem{.}
}
@defproc[(fmpg-buffer-end-sample [instance any/c])
exact-nonnegative-integer?]{
Returns the half-open end position of the current PCM buffer: the first sample
frame after the current buffer. The number of sample frames in the buffer is
the end position minus @racket[fmpg-buffer-start-sample]@elem{.} If no decoder
state is available, the result is @racket[0]@elem{.}
}
@defproc[(fmpg-sample-position [instance any/c])
exact-nonnegative-integer?]{
Returns the decoder's next sample-frame position after the current output.
During normal decoding it is the same as @racket[fmpg-buffer-end-sample] for
the current buffer. After a seek, it is reset to the target position before
new audio is decoded.
}
@section{FFmpeg version information}
@defproc[(ffmpeg-version [lib (or/c 'avutil 'avcodec 'avformat
'swr 'swresample)])
(list/c exact-nonnegative-integer?
exact-nonnegative-integer?
exact-nonnegative-integer?)]{
Returns the runtime version of one FFmpeg library as a three-element list
containing the major, minor, and micro version numbers. The symbols
@racket['swr] and @racket['swresample] both refer to @tt{libswresample}@elem{.}
The function raises an exception for an unknown library symbol.
}
@section{Use through the decoder frontend}
The direct API above is normally wrapped by @filepath{ffmpeg-ffi.rkt} and by
@filepath{ffmpeg-decoder.rkt}@elem{.} The frontend function
@tt{ffmpeg-open} returns a handle or @racket[#f] when the file does not exist.
Its stream-info callback receives a mutable hash with at least these playback
keys:
@racketblock[
(list 'sample-rate
'channels
'bits-per-sample
'bytes-per-sample
'total-samples
'duration)]
The audio callback receives the same hash extended for the current buffer with
these keys:
@racketblock[
(list 'sample
'current-time)]
The hash is followed by a copied byte string and its valid byte count. The
copy is made by @filepath{ffmpeg-ffi.rkt}@elem{,} not by the low-level buffer
function itself.
The frontend's seek function accepts a percentage of the stream and translates
that percentage to a sample position. The adapter then translates the sample
position to milliseconds and calls @racket[fmpg-seek-ms!]@elem{.} This is why
the low-level module exposes millisecond seeking while the frontend exposes
percentage seeking.
@section{Example}
The following example opens a file, decodes all PCM blocks, and reports their
byte ranges and sample ranges. A real playback loop would pass each buffer to
the audio output layer before requesting the next block.
@racketblock[
(define dec (fmpg-init))
(when (and dec (= (fmpg-open-file! dec "track.ogg") 1))
(printf "~a Hz, ~a channels, ~a ms\n"
(fmpg-audio-sample-rate dec)
(fmpg-audio-channels dec)
(fmpg-duration-ms dec))
(let loop ()
(when (= (fmpg-decode-next! dec) 1)
(define pcm (fmpg-buffer dec))
(define size (fmpg-buffer-size dec))
(define start (fmpg-buffer-start-sample dec))
(define end (fmpg-buffer-end-sample dec))
(printf "decoded ~a bytes, samples [~a, ~a)\n"
size start end)
;; Pass pcm to the audio output layer here, or copy it if needed.
(loop)))
(fmpg-close! dec))
]
A simple seek flow looks the same after the seek succeeds. The following code
moves to 30 seconds and then requests the next decoded buffer.
@racketblock[
(when (= (fmpg-seek-ms! dec 30000) 1)
(when (= (fmpg-decode-next! dec) 1)
(define pcm (fmpg-buffer dec))
(define start (fmpg-buffer-start-sample dec))
(printf "first buffer after seek starts at sample ~a\n" start)))
]
+142 -87
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
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.