Files
hans 65ca59bef8 audio-play! returns the file id.
The file id is now a randomized number
2026-05-17 20:19:06 +02:00

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.