Files
racket-audio/scrbl/audio-placed-player.scrbl
T

337 lines
16 KiB
Racket

#lang scribble/manual
@(require racket/runtime-path
(for-label racket/base
racket/contract
racket/place
racket/async-channel
uni-channel
port-channel
"../audio-placed-player.rkt"
"../audio-player.rkt"))
@(define-runtime-path placed-player-state-model-svg
"placed-player-state-model.svg")
@(define-runtime-path placed-player-worker-detail-model-svg
"placed-player-worker-detail-model.svg")
@title{Placed Audio Player}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@defmodule[racket-audio/audio-placed-player]
The @racketmodname[racket-audio/audio-placed-player] module contains the worker side
of the audio player. It is normally started by
@racket[make-audio-player] from @racketmodname[racket-audio/audio-player], and user
code should normally use that module's higher level procedures, such as
@racket[audio-play!], @racket[audio-pause!], @racket[audio-stop!],
@racket[audio-quit!], @racket[audio-seek!], and @racket[audio-volume!].
The placed player is implemented as a command loop around a decoder, an
asynchronous libao output handle, and a small amount of state that is reported
back to the controlling side. In normal use it runs in a Racket place, so that
the audio side has a separate Racket VM. The same function can also run in a
normal Racket thread with async channels. Both modes are mediated through
@racketmodname[uni-channel]: each of the existing logical channels is wrapped as
a uni-channel endpoint, so the worker no longer depends directly on
@racket[place-channel-put], @racket[place-channel-get],
@racket[async-channel-put], or @racket[async-channel-get].
It is normally run in a separate place so that audio decoding and feeding are
isolated from scheduling delays in the main Racket VM, such as GUI activity,
debugging, or interaction with DrRacket.
@section{Interface}
@defproc[(placed-player
[ch-in (or/c uni-channel? place-channel? async-channel? port-channel?)]
[ch-out (or/c #f uni-channel? place-channel? async-channel? port-channel?) #f]
[ch-evt (or/c #f uni-channel? place-channel? async-channel? port-channel?) #f])
void?]{
Runs the placed-player command loop on @racket[ch-in]. Each channel may already
be a @racket[uni-channel?] or may be a supported raw channel that can be wrapped
with @racket[make-uni-channel]. The command loop receives list commands,
initializes its reply and event channels, and then processes playback commands
until it receives @racket['quit].
When @racket[ch-out] and @racket[ch-evt] are @racket[#f], the command loop
expects an initial @racket['init] command containing the raw reply and event
channels. This remains the normal path for @racket[dynamic-place], because a
@racket[uni-channel] value contains procedures and must not be sent through a
place channel. In that case the controlling side sends the raw place channels
and the worker wraps them locally.
When all three channels are supplied, the worker starts initialized. This is
used by the standard-port worker entry point and can also be used by custom
launchers.}
@defproc[(placed-player/stdio [#:log-file log-file (or/c #f path-string?) (racket-sound-log-file 'placed-audio-player-stdio)])
void?]{
Runs @racket[placed-player] using the process standard streams as three logical
channels: @racket[current-input-port] is the command channel,
@racket[current-output-port] is the reply channel, and
@racket[current-error-port] is the asynchronous event channel. The streams are
wrapped through @racket[make-port-channel] and then through
@racket[make-uni-channel].
Because stdout and stderr are protocol streams in this mode, ordinary display
and log output is redirected while the worker is running. By default, output is
appended to a log file under Racket's standard cache directory, for example
@filepath{~/.cache/racket/racket-audio/placed-audio-player-stdio.log} on many
Unix-like systems. Pass @racket[#f] explicitly to discard ordinary output, or
pass a path to choose a different log file.
The module also has a command-line entry point. A remote or local subprocess
worker can be started with:
@verbatim{
racket -l racket-audio/audio-placed-player -- --stdio
}
This is the command used by @racket[make-audio-player] when SSH remote playback
is enabled, unless the caller supplies a custom remote command.}
The public wrapper in @racketmodname[racket-audio/audio-player] creates the channels,
sends the initial @racket['init] command when needed, starts an event thread, and exposes a
contracted API. The placed player exports @racket[placed-player] and the
standard-port worker entry point @racket[placed-player/stdio].
@section{Overall state model}
The logical player state is deliberately small. The stored value of
@racket[player-state] is one of @racket['stopped], @racket['playing], or
@racket['paused]. The state diagram below also shows protocol states around
initialization and termination.
@(image placed-player-state-model-svg)
The important command-level behaviour is:
@itemlist[#:style 'compact
@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. 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
does not change the logical player state.}
@item{@racket['stop] performs cleanup and returns to @racket['stopped].}
@item{@racket['quit] performs cleanup, emits a final forced state event, sends
the @racket['quit] reply, and exits the command loop.}]
A @racket['quit] command is part of the valid protocol after initialization. A
@racket['quit] before @racket['init] is not a normal use case: cleanup emits
state information through the event channel, and that channel has not yet been
installed.
@section{Command protocol}
The controlling side sends commands as lists on @racket[ch-in]. The result of
an RPC-style command is sent on the reply channel installed by
@racket['init] or supplied directly to @racket[placed-player]. Asynchronous
events are sent on the event channel. These are logical channels; the concrete
transport may be place channels, async channels, or port channels wrapped as
uni-channels.
@itemlist[#:style 'compact
@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[(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)].}
@item{@racket[(list 'paused)] replies with a one-element list containing a
boolean.}
@item{@racket[(list 'seek percentage)] clears the output queue, seeks the
decoder if present, and replies with @racket['(ok)].}
@item{@racket[(list 'volume percentage)] stores a requested volume percentage,
and replies with @racket['(ok)]. The worker applies the change when it
next feeds audio.}
@item{@racket[(list 'get-volume)] replies with the current volume in a
one-element list.}
@item{@racket[(list 'buf-seconds min max)] configures the output buffering
range. The values are clamped by the placed player.}
@item{@racket[(list 'stop)] calls the cleanup path, returns to
@racket['stopped], and replies with @racket['(ok)].}
@item{@racket[(list 'state)] builds a forced state snapshot and replies with
the state event payload.}
@item{@racket[(list 'quit)] calls the cleanup path, emits a final state event,
replies with @racket['(quit)], and terminates the loop.}]
Unknown commands are caught inside the initialized loop and receive an
@racket['error] reply. Exceptions during an RPC command are reported both as an
@racket['exception] event and, when possible, as an @racket['error] reply for
the current RPC.
@section{Worker and decoder lifecycle}
Opening a file creates a decoder with @racket[audio-open]. The decoder is
called with two callbacks: one for metadata and one for audio buffers. Metadata
is stored for later state reporting. Audio buffers are passed to the libao
asynchronous player by @racket[ao-play].
The decoder is read by a Racket thread created by @racket[audio-read-worker].
That thread is separate from the command loop, even when the whole placed player
is already running inside a Racket place. This lets the command loop continue
to receive commands while decoding and output buffering are active.
@(image placed-player-worker-detail-model-svg)
The most important internal flags are:
@itemlist[#:style 'compact
@item{@racket[feeding-audio] records that a worker is still active. The
command loop uses it when replacing the current file.}
@item{@racket[feed-interrupted] tells the worker that its current read was
intentionally aborted by @racket['open], @racket['stop], or cleanup.}
@item{@racket[current-file-id] identifies the latest file. A worker that is
draining old audio may clean itself up, but only the current worker may
move the global state to @racket['stopped].}
@item{@racket[play-thread] is the Racket thread that runs the decoder read.}
@item{@racket[ao-h] is the asynchronous output handle. Access is protected by
@racket[ao-mutex] and by the local @racket[with-ao-h] form.}]
When @racket[audio-read] returns normally, the worker emits an
@racket['audio-done] event and then waits for the asynchronous output queue to
finish playing. This is necessary because the decoder may be done while libao
still has queued PCM samples. If the queue becomes empty and the worker still
belongs to the current file id, the worker changes @racket[player-state] to
@racket['stopped] and emits a state update.
If a new file is opened while a worker is still feeding audio, the command loop
sets @racket[feed-interrupted], clears the output queue, stops the decoder, and
waits for the worker to finish before starting the next decoder. This prevents
old decoder data and new decoder data from being mixed in the output queue.
@section{Audio output and buffering}
The @racket[audio-play] callback is called by the decoder for each decoded audio
buffer. It updates the current decoder buffer information, opens or reopens the
libao output handle when the sample format changes, applies pending volume
changes, queues the buffer, and publishes state updates when the playback second
changes.
The player keeps a minimum and maximum buffer target. When the asynchronous
output queue grows above the configured maximum, the callback waits until the
queue drops below the configured minimum. During that wait it still observes
pause changes and continues to publish coarse-grained position updates.
A format change requires special care. If the sample width, rate, or channel
count changes, the existing output queue must drain before the output handle is
closed and reopened with the new format. The code waits for the buffer to
become empty before closing the old handle.
@section{Pause, seek, and volume}
Pause is represented only as logical player state. The command loop changes
@racket[player-state], and the worker checks that state while feeding or
waiting. When the state is @racket['paused], the worker calls
@racket[ao-pause] with @racket[#t] and waits until the state changes. When the
state changes away from @racket['paused], it calls @racket[ao-pause] with
@racket[#f].
Seek does not change @racket[player-state]. It clears the output queue and
asks the decoder to seek to the given percentage. If the player was playing, it
continues as playing. If it was paused, it remains paused.
Volume changes are staged in @racket[req-volume]. The audio callback compares
@racket[req-volume] with @racket[current-volume] and applies the change to the
output handle when audio is being processed.
@section{State snapshots and events}
The placed player has two outgoing channels after initialization:
@racket[ch-out] for synchronous RPC replies, and @racket[ch-evt] for
asynchronous events. The high-level wrapper in @racketmodname["audio-player.rkt"]
uses a separate event thread to consume @racket[ch-evt], cache the latest state
hash in the audio-player handle, and call the user-supplied callbacks.
State snapshots are built by the internal @racket[state] procedure. The hash
contains operational information such as:
@itemlist[#:style 'compact
@item{@racket['state], @racket['msg], @racket['file], and
@racket['valid-ao-handle];}
@item{@racket['duration], @racket['at-second], and @racket['at-music-id];}
@item{@racket['volume], @racket['buf-size], @racket['sample-queue-len], and
@racket['reuse-buf-len];}
@item{@racket['bits], @racket['rate], @racket['channels],
@racket['decoder], @racket['decoder-meta], and
@racket['decoder-buf-info].}]
Most state events are suppressed until there is a valid music id. Forced state
snapshots bypass that suppression. Forced snapshots are used for the explicit
@racket['state] command and for cleanup paths such as @racket['stop] and
@racket['quit].
The asynchronous event stream currently uses these event shapes:
@itemlist[#:style 'compact
@item{@racket[(list 'state hash)] for state updates;}
@item{@racket['(audio-done)] when the decoder has finished reading the current
stream;}
@item{@racket[(list 'exception message)] when the worker or command loop
reports an exception.}]
@section{Stop, cleanup, and quit}
@racket[stop-and-cleanup] is shared by @racket['stop] and @racket['quit]. It
marks the feed as interrupted, clears the output queue, moves the logical state
to @racket['stopped], stops the decoder when present, waits for the play thread,
closes the output handle, resets the internal bookkeeping fields, and emits a
forced state event.
The difference between @racket['stop] and @racket['quit] is what happens after
cleanup. @racket['stop] replies with @racket['(ok)] and continues the command
loop. @racket['quit] emits an additional forced state event with the message
@racket["quit"], replies with @racket['(quit)], and returns from the loop. The
place or thread then terminates.
@section{Running in a place or in a thread}
The normal path in @racket[make-audio-player] uses @racket[dynamic-place] when
places are enabled. The command, reply, and event channels are wrapped with
@racket[make-uni-channel] on each side. This gives the audio side its own Racket VM and isolates
it from the main controller, while the command and event protocol stays the
same.
For debugging, @racket[make-audio-player] can be called with
@racket[#:use-place #f]. In that mode, the placed player is started in a
normal Racket thread and communicates through async channels:
@racketblock[
(define player
(make-audio-player
(lambda (handle state-hash)
(void))
(lambda (handle)
(void))
#:use-place #f))
(audio-play! player "track.flac")
(audio-pause! player #t)
(audio-pause! player #f)
(audio-stop! player)
(audio-quit! player)]
The thread mode uses the same command protocol and the same worker code. It is
therefore useful for reproducing and debugging player behaviour before moving
back to the place-based configuration.
The place-based mode is the preferred mode for playback. A place runs in a
separate Racket virtual machine, with its own scheduler state, and communicates
with the main program only through explicit messages. This matters for audio:
the audio backend must be fed regularly, and small scheduling delays can already
show up as clicks, gaps, or stuttering playback. When the player runs in the
same VM as DrRacket, a GUI application, logging, debugging, or other active
threads, those activities can delay the audio feeder at the wrong moment. By
running the player in a place, the playback pipeline gets a quieter execution
environment. Running the same command loop in an ordinary thread is useful for
debugging, because normal asynchronous channels are easier to inspect, but it is
not the preferred mode for robust playback.