diff --git a/info.rkt b/info.rkt index 2bc0aa6..26b68f2 100644 --- a/info.rkt +++ b/info.rkt @@ -16,6 +16,8 @@ ("scrbl/ffmpeg-ffi.scrbl" () (library)) ("scrbl/ffmpeg-decoder.scrbl" () (library)) ("scrbl/ffmpeg-c-backend.scrbl" () (library)) + ("scrbl/ffmpeg-definitions.scrbl" () (library)) + ("scrbl/libao-async-ffi-racket.scrbl" () (library)) ) ) diff --git a/libao-async-ffi-racket.rkt b/libao-async-ffi-racket.rkt index 39fdf0d..e94a924 100644 --- a/libao-async-ffi-racket.rkt +++ b/libao-async-ffi-racket.rkt @@ -194,7 +194,7 @@ ;; Playback buffer to send to libao in milliseconds ;; ------------------------------------------------------------------------- -(define ao-buf-ms 250) ;; Playback buffer of 0.25s +(define ao-buf-ms 1000) ;; Playback buffer of 0.25s ;; ------------------------------------------------------------------------- ;; Sample queue handling diff --git a/scrbl/ffmpeg-definitions.scrbl b/scrbl/ffmpeg-definitions.scrbl new file mode 100644 index 0000000..a71ea31 --- /dev/null +++ b/scrbl/ffmpeg-definitions.scrbl @@ -0,0 +1,398 @@ +#lang scribble/manual + +@(require (for-label racket/base + ;racket/contract + racket/path + ffi/unsafe + let-assert + define-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))) +] \ No newline at end of file diff --git a/scrbl/libao-async-ffi-racket.scrbl b/scrbl/libao-async-ffi-racket.scrbl new file mode 100644 index 0000000..e89cd1c --- /dev/null +++ b/scrbl/libao-async-ffi-racket.scrbl @@ -0,0 +1,265 @@ +#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. \ No newline at end of file