diff --git a/audio-placed-player.rkt b/audio-placed-player.rkt index 0cc63a4..8287a43 100644 --- a/audio-placed-player.rkt +++ b/audio-placed-player.rkt @@ -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)))) @@ -316,8 +318,10 @@ (when (thread? play-thread) (thread-wait play-thread)) (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))) diff --git a/audio-player.rkt b/audio-player.rkt index 1822292..ae98263 100644 --- a/audio-player.rkt +++ b/audio-player.rkt @@ -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?) diff --git a/play-test.rkt b/play-test.rkt index e00d33c..f4dc5e5 100644 --- a/play-test.rkt +++ b/play-test.rkt @@ -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 diff --git a/scrbl/audio-placed-player.scrbl b/scrbl/audio-placed-player.scrbl index 596aa3c..5ef3c61 100644 --- a/scrbl/audio-placed-player.scrbl +++ b/scrbl/audio-placed-player.scrbl @@ -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)].} diff --git a/scrbl/audio-player.scrbl b/scrbl/audio-player.scrbl index 6667168..d59d215 100644 --- a/scrbl/audio-player.scrbl +++ b/scrbl/audio-player.scrbl @@ -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 diff --git a/scrbl/ffmpeg-definitions.scrbl b/scrbl/ffmpeg-definitions.scrbl index 989c060..e69de29 100644 --- a/scrbl/ffmpeg-definitions.scrbl +++ b/scrbl/ffmpeg-definitions.scrbl @@ -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))) -] \ No newline at end of file diff --git a/scrbl/libao-async-ffi-racket.scrbl b/scrbl/libao-async-ffi-racket.scrbl index e89cd1c..3752917 100644 --- a/scrbl/libao-async-ffi-racket.scrbl +++ b/scrbl/libao-async-ffi-racket.scrbl @@ -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. \ No newline at end of file +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.