358 lines
15 KiB
Racket
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[#:tag "audio-player-example"]{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.
|