Files
gemigreerd-racket-audio/scrbl/audio-player.scrbl
T
2026-05-19 14:30:57 +02:00

358 lines
15 KiB
Racket

#lang scribble/manual
@(require (for-label racket/base
racket/contract
racket/path
racket/place
"../audio-player.rkt"))
@title{Audio Player}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@defmodule[racket-audio/audio-player]
The @racketmodname[racket-audio/audio-player] module is the high level interface for
audio playback. It hides the command protocol of
@racketmodname[racket-audio/audio-placed-player], creates the playback place or
thread, receives asynchronous events, and exposes a small handle-based API for
starting, pausing, seeking, stopping, and observing playback.
The player is asynchronous. Playback commands are sent to a worker side and
normally return after the command has been accepted, not after all audio has
finished playing. State changes and end-of-stream notifications are delivered
through callbacks supplied when the player is created.
@section{Creating a player}
@defproc[(make-audio-player
[cb-state procedure?]
[cb-eof-stream procedure?]
[#:use-place use-place boolean?])
audio-play?]{
Creates an audio player and returns a player handle. The handle is passed to
all other procedures in this module.
The @racket[cb-state] callback is called as:
@racketblock[
(cb-state player current-player-state state-hash)]
where @racket[player] is the player handle,
@racket[current-player-state] is the logical player state reported by the
worker, and @racket[state-hash] is the most recent state snapshot received from
the worker side. The callback is called from the event thread created by
@racket[make-audio-player].
The worker-side player state is one of the following symbols:
@itemlist[
#:style 'compact
@item{@racket['stopped] -- no stream is currently playing. This is the
initial state of the placed player. The player also enters this state after
@racket[audio-stop!] or after the decoder has reached the end of the stream
and the libao output queue has drained.}
@item{@racket['playing] -- a stream is active. The decoder may still be
reading from the input file, or the decoder may already have finished while
libao is still playing queued PCM samples.}
@item{@racket['paused] -- playback is paused. The current stream is retained
and the libao output side is paused. Resuming playback moves the player back
to @racket['playing].}
@item{@racket['quit] -- the placed player has been asked to terminate. This
is the terminal state of the worker.}
]
The wrapper around the placed player may also report these states through
@racket[audio-state]:
@itemlist[
#:style 'compact
@item{@racket['initialized] -- the audio handle has been created, but no
worker-side state snapshot has been received yet.}
@item{@racket['invalid] -- the audio handle is no longer valid. This happens
after @racket[audio-quit!] or when the underlying place or thread has stopped.}
]
The @racket[state-hash] contains the detailed playback state reported by the
worker. It includes values such as the current playback position, stream
duration, buffer status, music id, and libao handle validity. Code that only
needs the logical playback state should use @racket[current-player-state]
instead of extracting it from the hash.
The @racket[cb-eof-stream] callback is called as:
@racketblock[
(cb-eof-stream player)]
when the decoder reports that the current stream has been read. This means
that the decoder has finished queueing the stream. The audio device may still
have buffered samples to play, and the logical player state may move to
@racket['stopped] slightly later when the output queue has drained.
End-of-stream is not represented as a separate player state.
When @racket[use-place] is true, @racket[make-audio-player] starts
@racket[placed-player] with @racket[dynamic-place] and communicates with it
through place channels. When @racket[use-place] is false, the same command loop
is started in an ordinary Racket thread and communicates through async channels.
The default value is @racket[(place-enabled?)].
The place-based mode is the normal playback mode. A place gives the audio side
a separate Racket VM, so decoding and buffer feeding are less exposed to
scheduling delays caused by DrRacket, GUI event handling, debugging, logging, or
other active threads in the main VM. Those delays can otherwise be heard as
clicks, gaps, or stuttering playback. Thread mode is useful for debugging the
protocol and callbacks, but it is not the preferred mode for robust playback.}
@defproc[(audio-play? [v any/c]) boolean?]{
Returns @racket[#t] when @racket[v] is a currently valid audio player handle.
The predicate is intentionally stricter than merely recognizing the underlying
structure. After @racket[audio-quit!] or after the worker has died, the handle
is invalidated and @racket[audio-play?] returns @racket[#f].}
@section{Basic playback}
@defproc[(audio-play! [player audio-play?]
[audio-file path-string?])
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[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
queue, waits for the old worker thread, and then starts the new stream.}
@defproc[(audio-pause! [player audio-play?]
[paused? boolean?])
symbol?]{
Pauses or resumes playback. Passing @racket[#t] moves the logical player state
to @racket['paused]. Passing @racket[#f] moves it back to @racket['playing].
In normal use the return value is @racket['ok].
Pause is implemented as player state observed by the worker. The worker
translates the state to calls to the asynchronous audio backend.}
@defproc[(audio-paused? [player audio-play?]) boolean?]{
Returns whether the worker currently reports the logical player state as
@racket['paused]. This is an RPC-style query to the worker side.}
@defproc[(audio-stop! [player audio-play?]) symbol?]{
Stops the current stream, clears the audio output queue, closes the active
decoder and output handle when present, and returns the logical state to
@racket['stopped]. In normal use the return value is @racket['ok].
The player remains valid after @racket[audio-stop!], so another
@racket[audio-play!] call can be used to start a new file.}
@defproc[(audio-quit! [player audio-play?])
(or/c number? boolean? symbol?)]{
Stops playback, performs the same cleanup as @racket[audio-stop!], sends a
@racket['quit] command to the worker side, and invalidates the handle. In
normal use the return value is @racket['quit].
After this procedure returns, the command loop in the place or thread is
expected to terminate. Most other operations require @racket[audio-play?] and
therefore should not be used on the handle after quitting. The implementation
also registers a finalizer that sends @racket['quit] when a still-valid handle
is collected, but explicit shutdown with @racket[audio-quit!] is preferred.}
@section{Position, volume, and buffering}
@defproc[(audio-seek! [player audio-play?]
[percentage (and/c number? (>=/c 0) (<=/c 100))])
symbol?]{
Seeks the current stream to @racket[percentage], where @racket[0] is the start
and @racket[100] is the end. The output queue is cleared before the decoder is
asked to seek. In normal use the return value is @racket['ok].
Seeking does not change the logical playback state. A playing stream remains
playing, and a paused stream remains paused.}
@defproc[(audio-volume! [player audio-play?]
[percentage (and/c number? (>=/c 0))])
symbol?]{
Requests a new playback volume. The value is stored by the worker and applied
to the audio output side when audio is processed. In normal use the return
value is @racket['ok].}
@defproc[(audio-volume [player audio-play?]) number?]{
Returns the current volume value known by the worker.}
@defproc[(audio-buf-seconds! [player audio-play?]
[min number?]
[max number?])
(or/c symbol? boolean?)]{
Configures the output buffering range, in seconds. The worker tries to keep
the queued audio between the requested lower and upper bounds while decoding.
The wrapper normalizes the values before sending the command. A @racket[min]
below @racket[1] is raised to @racket[1], and a @racket[min] above @racket[10]
is lowered to @racket[10]. A @racket[max] below @racket[min] is changed to
@racket[(+ min 1)], and a @racket[max] above @racket[30] is lowered to
@racket[30]. The worker side applies its own safe ordering and clamping before
using the values. In normal use the return value is @racket['ok].}
@deftogether[
(@defproc[(audio-ao-buf-ms! [handle audio-play?]
[ms integer?])
(or/c integer? boolean?)]
@defproc[(audio-ao-buf-ms [handle audio-play?])
(or/c integer? boolean?)])
]{
Sets or queries the libao output buffer size, expressed in milliseconds.
The @racket[audio-ao-buf-ms!] procedure forwards @racket[ms] to the audio
player backend by sending the @racket['ao-buf-ms] RPC command. This hooks
into the libao-side buffer configuration and can be used to tune the amount of
audio data that the output layer keeps ahead of playback.
The @racket[audio-ao-buf-ms] procedure queries the currently configured value
by sending the same RPC command without a new value.
The returned value is the value reported by the backend. Normally this is an
integer number of milliseconds. A boolean result indicates that the value could
not be set or queried, or that the backend reported a non-numeric status.
Larger buffer values can make playback more robust against short scheduling
delays, but also increase latency. Smaller values reduce latency, but may make
drop-outs more likely when the decoder or GUI thread is temporarily delayed.
The value is clamped between 50 and 1000ms.
@racketblock[
(audio-ao-buf-ms! player 500)
(audio-ao-buf-ms player)
]
}
@section{State snapshots}
The player keeps a local cache of the most recent state snapshot received from
the worker. The cache is updated by an event thread created by
@racket[make-audio-player]. The state accessor procedures below read this
local cache; they do not synchronously ask the worker for a fresh state.
Before the first state event has arrived, most accessors return @racket[#f].
The logical state accessor returns @racket['initialized] for a valid handle
whose state hash does not yet contain a @racket['state] entry. If the worker
dies or the handle is explicitly invalidated, @racket[audio-state] returns
@racket['invalid].
@defproc[(audio-full-state [player audio-play?]) hash?]{
Returns the complete cached state hash. The hash is the payload of the most
recent @racket['state] event. It may contain keys such as @racket['state],
@racket['file], @racket['duration], @racket['at-second],
@racket['at-music-id], @racket['volume], @racket['bits], @racket['rate],
@racket['channels], @racket['decoder], @racket['decoder-meta], and
@racket['decoder-buf-info].}
@defproc[(audio-state [player audio-play?]) symbol?]{
Returns the cached logical player state. Typical values are
@racket['initialized], @racket['stopped], @racket['playing],
@racket['paused], and @racket['invalid].}
@defproc[(audio-at-second [player audio-play?]) (or/c number? boolean?)]{
Returns the cached playback position in seconds, or @racket[#f] when no
position is known yet.}
@defproc[(audio-duration [player audio-play?]) (or/c number? boolean?)]{
Returns the cached stream duration in seconds, or @racket[#f] when the duration
is not known.}
@defproc[(audio-file [player audio-play?]) (or/c path-string? boolean?)]{
Returns the cached path of the file currently associated with the active music
id, or @racket[#f] when no such file is known.}
@defproc[(audio-music-id [player audio-play?]) (or/c number? boolean?)]{
Returns the cached music id used by the asynchronous output side, or
@racket[#f] when no output handle is active.}
@defproc[(audio-bits [player audio-play?]) (or/c number? boolean?)]{
Returns the cached sample width in bits, or @racket[#f] when the format is not
known.}
@defproc[(audio-rate [player audio-play?]) (or/c number? boolean?)]{
Returns the cached sample rate, or @racket[#f] when the format is not known.}
@defproc[(audio-channels [player audio-play?]) (or/c number? boolean?)]{
Returns the cached channel count, or @racket[#f] when the format is not known.}
@defproc[(audio-decoder [player audio-play?]) (or/c symbol? boolean?)]{
Returns the cached decoder kind, or @racket[#f] when no decoder kind is known.}
@section{Events and callbacks}
The wrapper receives asynchronous events from the worker side. A state event
updates the cached state hash and calls @racket[cb-state]. An
@racket['audio-done] event calls @racket[cb-eof-stream]. Unknown events are
reported through the module's warning mechanism.
Callbacks run in the event thread owned by the player handle. They should
therefore be quick, should not block for long periods, and should avoid
performing complicated UI work directly. A GUI program can use the callbacks
to enqueue work onto the GUI eventspace instead.
The RPC command path is protected by a mutex in the wrapper. This allows
different application threads to call playback procedures on the same handle
without interleaving the command and reply parts of a single RPC.
@section{Example}
The following example creates a player, prints state changes, plays a file, and
then shuts the player down explicitly.
For a larger integration example, see @filepath{play-test.rkt}. The queue
variant in that file, selected with @code{(set-test 'queue)}, is documented separately in @filepath{play-test.scrbl}.
@codeblock{
#lang racket/base
(require "audio-player.rkt")
(define player
(make-audio-player
(lambda (p st)
(printf "state: ~a at ~a seconds\n"
(hash-ref st 'state #f)
(hash-ref st 'at-second #f)))
(lambda (p)
(printf "decoder reached end of stream\n"))))
(audio-play! player "track.flac")
;; Later, for example in response to user input:
(audio-pause! player #t)
(audio-pause! player #f)
(audio-seek! player 50)
(audio-volume! player 80)
(audio-stop! player)
;; When the player is no longer needed:
(audio-quit! player)}
For debugging the worker in the same Racket VM, create the player with
@racket[#:use-place #f]:
@racketblock[
(define debug-player
(make-audio-player
(lambda (p st) (void))
(lambda (p) (void))
#:use-place #f))]
This uses the same command loop and event handling, but starts the worker side
in a normal Racket thread instead of a place.