#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.} @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['(ok)].} @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.