65ca59bef8
The file id is now a randomized number
293 lines
14 KiB
Racket
293 lines
14 KiB
Racket
#lang scribble/manual
|
|
|
|
@(require racket/runtime-path
|
|
(for-label racket/base
|
|
racket/contract
|
|
racket/place
|
|
racket/async-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. That mode is useful for debugging,
|
|
because the player then stays in the same process and can be inspected more
|
|
easily.
|
|
|
|
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 place-channel? async-channel?)]) void?]{
|
|
Runs the placed-player command loop on @racket[ch-in]. The channel may be a
|
|
place channel or an async channel. The command loop receives list commands,
|
|
initializes its reply and event channels, and then processes playback commands
|
|
until it receives @racket['quit].
|
|
|
|
The function is designed to be started either by @racket[dynamic-place] or by
|
|
@racket[thread]. In place mode, all three channels are place channels. In
|
|
thread mode, all three channels are async channels. The implementation detects
|
|
the kind of channel and uses @racket[place-channel-put],
|
|
@racket[place-channel-get], @racket[async-channel-put], or
|
|
@racket[async-channel-get] as appropriate.}
|
|
|
|
The public wrapper in @racketmodname[racket-audio/audio-player] creates the channels,
|
|
sends the initial @racket['init] command, starts an event thread, and exposes a
|
|
contracted API. The placed player itself only exports @racket[placed-player].
|
|
|
|
@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]. Asynchronous events are sent on the event channel.
|
|
|
|
@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. 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.
|