6 Placed Audio Player
| (require racket-audio/audio-placed-player) | |
| package: racket-audio | |
The racket-audio/audio-placed-player module contains the worker side of the audio player. It is normally started by make-audio-player from racket-audio/audio-player, and user code should normally use that module’s higher level procedures, such as audio-play!, audio-pause!, audio-stop!, audio-quit!, audio-seek!, and 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.
6.1 Interface
procedure
(placed-player ch-in) → void?
ch-in : (or/c place-channel? async-channel?)
The function is designed to be started either by dynamic-place or by 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 place-channel-put, place-channel-get, async-channel-put, or async-channel-get as appropriate.
The public wrapper in racket-audio/audio-player creates the channels, sends the initial 'init command, starts an event thread, and exposes a contracted API. The placed player itself only exports placed-player.
6.2 Overall state model
The logical player state is deliberately small. The stored value of player-state is one of 'stopped, 'playing, or 'paused. The state diagram below also shows protocol states around initialization and termination.
The important command-level behaviour is:
'init installs the reply and event channels and moves the player into the initialized command loop.
'open starts a decoder and a read worker. If another worker is still feeding audio, it is first interrupted and joined.
'pause only changes player-state. The worker observes that state and applies ao-pause to the output side.
'seek clears the async output queue and seeks the decoder, but does not change the logical player state.
'stop performs cleanup and returns to 'stopped.
'quit performs cleanup, emits a final forced state event, sends the 'quit reply, and exits the command loop.
A 'quit command is part of the valid protocol after initialization. A 'quit before 'init is not a normal use case: cleanup emits state information through the event channel, and that channel has not yet been installed.
6.3 Command protocol
The controlling side sends commands as lists on ch-in. The result of an RPC-style command is sent on the reply channel installed by 'init. Asynchronous events are sent on the event channel.
(list 'init ch-out ch-evt) installs ch-out and ch-evt, and replies with '(initialized).
(list 'open file) opens file, starts the decoder worker, and replies with '(ok).
(list 'pause paused?) sets player-state to 'paused or 'playing when the player is already active, and replies with '(ok).
(list 'paused) replies with a one-element list containing a boolean.
(list 'seek percentage) clears the output queue, seeks the decoder if present, and replies with '(ok).
(list 'volume percentage) stores a requested volume percentage, and replies with '(ok). The worker applies the change when it next feeds audio.
(list 'get-volume) replies with the current volume in a one-element list.
(list 'buf-seconds min max) configures the output buffering range. The values are clamped by the placed player.
(list 'stop) calls the cleanup path, returns to 'stopped, and replies with '(ok).
(list 'state) builds a forced state snapshot and replies with the state event payload.
(list 'quit) calls the cleanup path, emits a final state event, replies with '(quit), and terminates the loop.
Unknown commands are caught inside the initialized loop and receive an 'error reply. Exceptions during an RPC command are reported both as an 'exception event and, when possible, as an 'error reply for the current RPC.
6.4 Worker and decoder lifecycle
Opening a file creates a decoder with 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 ao-play.
The decoder is read by a Racket thread created by 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.
The most important internal flags are:
feeding-audio records that a worker is still active. The command loop uses it when replacing the current file.
feed-interrupted tells the worker that its current read was intentionally aborted by 'open, 'stop, or cleanup.
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 'stopped.
play-thread is the Racket thread that runs the decoder read.
ao-h is the asynchronous output handle. Access is protected by ao-mutex and by the local with-ao-h form.
When audio-read returns normally, the worker emits an '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 player-state to 'stopped and emits a state update.
If a new file is opened while a worker is still feeding audio, the command loop sets 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.
6.5 Audio output and buffering
The 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.
6.6 Pause, seek, and volume
Pause is represented only as logical player state. The command loop changes player-state, and the worker checks that state while feeding or waiting. When the state is 'paused, the worker calls ao-pause with #t and waits until the state changes. When the state changes away from 'paused, it calls ao-pause with #f.
Seek does not change 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 req-volume. The audio callback compares req-volume with current-volume and applies the change to the output handle when audio is being processed.
6.7 State snapshots and events
The placed player has two outgoing channels after initialization: ch-out for synchronous RPC replies, and ch-evt for asynchronous events. The high-level wrapper in "audio-player.rkt" uses a separate event thread to consume 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 state procedure. The hash contains operational information such as:
'state, 'msg, 'file, and 'valid-ao-handle;
'duration, 'at-second, and 'at-music-id;
'volume, 'buf-size, 'sample-queue-len, and 'reuse-buf-len;
'bits, 'rate, 'channels, 'decoder, 'decoder-meta, and '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 'state command and for cleanup paths such as 'stop and 'quit.
The asynchronous event stream currently uses these event shapes:
6.8 Stop, cleanup, and quit
stop-and-cleanup is shared by 'stop and 'quit. It marks the feed as interrupted, clears the output queue, moves the logical state to '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 'stop and 'quit is what happens after cleanup. 'stop replies with '(ok) and continues the command loop. 'quit emits an additional forced state event with the message "quit", replies with '(quit), and returns from the loop. The place or thread then terminates.
6.9 Running in a place or in a thread
The normal path in make-audio-player uses 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, make-audio-player can be called with #:use-place #f. In that mode, the placed player is started in a normal Racket thread and communicates through async channels:
(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.