#lang scribble/manual @(require (for-label racket/base racket/contract racket/path racket/place "../audio-player.rkt")) @title{Audio Player} @author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]] @defmodule[racket-audio/audio-player] The @racketmodname[racket-audio/audio-player] module is the high level interface for audio playback. It hides the command protocol of @racketmodname[racket-audio/audio-placed-player], creates the playback place or thread, receives asynchronous events, and exposes a small handle-based API for starting, pausing, seeking, stopping, and observing playback. The player is asynchronous. Playback commands are sent to a worker side and normally return after the command has been accepted, not after all audio has finished playing. State changes and end-of-stream notifications are delivered through callbacks supplied when the player is created. @section{Creating a player} @defproc[(make-audio-player [cb-state procedure?] [cb-eof-stream procedure?] [#:use-place use-place boolean?]) audio-play?]{ Creates an audio player and returns a player handle. The handle is passed to all other procedures in this module. The @racket[cb-state] callback is called as: @racketblock[ (cb-state player current-player-state state-hash)] where @racket[player] is the player handle, @racket[current-player-state] is the logical player state reported by the worker, and @racket[state-hash] is the most recent state snapshot received from the worker side. The callback is called from the event thread created by @racket[make-audio-player]. The worker-side player state is one of the following symbols: @itemlist[ #:style 'compact @item{@racket['stopped] -- no stream is currently playing. This is the initial state of the placed player. The player also enters this state after @racket[audio-stop!] or after the decoder has reached the end of the stream and the libao output queue has drained.} @item{@racket['playing] -- a stream is active. The decoder may still be reading from the input file, or the decoder may already have finished while libao is still playing queued PCM samples.} @item{@racket['paused] -- playback is paused. The current stream is retained and the libao output side is paused. Resuming playback moves the player back to @racket['playing].} @item{@racket['quit] -- the placed player has been asked to terminate. This is the terminal state of the worker.} ] The wrapper around the placed player may also report these states through @racket[audio-state]: @itemlist[ #:style 'compact @item{@racket['initialized] -- the audio handle has been created, but no worker-side state snapshot has been received yet.} @item{@racket['invalid] -- the audio handle is no longer valid. This happens after @racket[audio-quit!] or when the underlying place or thread has stopped.} ] The @racket[state-hash] contains the detailed playback state reported by the worker. It includes values such as the current playback position, stream duration, buffer status, music id, and libao handle validity. Code that only needs the logical playback state should use @racket[current-player-state] instead of extracting it from the hash. The @racket[cb-eof-stream] callback is called as: @racketblock[ (cb-eof-stream player)] when the decoder reports that the current stream has been read. This means that the decoder has finished queueing the stream. The audio device may still have buffered samples to play, and the logical player state may move to @racket['stopped] slightly later when the output queue has drained. End-of-stream is not represented as a separate player state. When @racket[use-place] is true, @racket[make-audio-player] starts @racket[placed-player] with @racket[dynamic-place] and communicates with it through place channels. When @racket[use-place] is false, the same command loop is started in an ordinary Racket thread and communicates through async channels. The default value is @racket[(place-enabled?)]. The place-based mode is the normal playback mode. A place gives the audio side a separate Racket VM, so decoding and buffer feeding are less exposed to scheduling delays caused by DrRacket, GUI event handling, debugging, logging, or other active threads in the main VM. Those delays can otherwise be heard as clicks, gaps, or stuttering playback. Thread mode is useful for debugging the protocol and callbacks, but it is not the preferred mode for robust playback.} @defproc[(audio-play? [v any/c]) boolean?]{ Returns @racket[#t] when @racket[v] is a currently valid audio player handle. The predicate is intentionally stricter than merely recognizing the underlying structure. After @racket[audio-quit!] or after the worker has died, the handle is invalidated and @racket[audio-play?] returns @racket[#f].} @section{Basic playback} @defproc[(audio-play! [player audio-play?] [audio-file path-string?]) number?]{ Starts playback of @racket[audio-file]. The file is opened by the worker side, a decoder is selected, and audio feeding begins asynchronously. In normal use the return value is @racket[music-id], where @tt{music-id} is the given @tt{id}, a @tt{number? >= 1} to the file to play, which will be used when reporting in callbacks about e.g. state. Calling @racket[audio-play!] while another file is still active replaces the current stream. The worker side interrupts the old decoder, clears the output queue, waits for the old worker thread, and then starts the new stream.} @defproc[(audio-pause! [player audio-play?] [paused? boolean?]) symbol?]{ Pauses or resumes playback. Passing @racket[#t] moves the logical player state to @racket['paused]. Passing @racket[#f] moves it back to @racket['playing]. In normal use the return value is @racket['ok]. Pause is implemented as player state observed by the worker. The worker translates the state to calls to the asynchronous audio backend.} @defproc[(audio-paused? [player audio-play?]) boolean?]{ Returns whether the worker currently reports the logical player state as @racket['paused]. This is an RPC-style query to the worker side.} @defproc[(audio-stop! [player audio-play?]) symbol?]{ Stops the current stream, clears the audio output queue, closes the active decoder and output handle when present, and returns the logical state to @racket['stopped]. In normal use the return value is @racket['ok]. The player remains valid after @racket[audio-stop!], so another @racket[audio-play!] call can be used to start a new file.} @defproc[(audio-quit! [player audio-play?]) (or/c number? boolean? symbol?)]{ Stops playback, performs the same cleanup as @racket[audio-stop!], sends a @racket['quit] command to the worker side, and invalidates the handle. In normal use the return value is @racket['quit]. After this procedure returns, the command loop in the place or thread is expected to terminate. Most other operations require @racket[audio-play?] and therefore should not be used on the handle after quitting. The implementation also registers a finalizer that sends @racket['quit] when a still-valid handle is collected, but explicit shutdown with @racket[audio-quit!] is preferred.} @section{Position, volume, and buffering} @defproc[(audio-seek! [player audio-play?] [percentage (and/c number? (>=/c 0) (<=/c 100))]) symbol?]{ Seeks the current stream to @racket[percentage], where @racket[0] is the start and @racket[100] is the end. The output queue is cleared before the decoder is asked to seek. In normal use the return value is @racket['ok]. Seeking does not change the logical playback state. A playing stream remains playing, and a paused stream remains paused.} @defproc[(audio-volume! [player audio-play?] [percentage (and/c number? (>=/c 0))]) symbol?]{ Requests a new playback volume. The value is stored by the worker and applied to the audio output side when audio is processed. In normal use the return value is @racket['ok].} @defproc[(audio-volume [player audio-play?]) number?]{ Returns the current volume value known by the worker.} @defproc[(audio-buf-seconds! [player audio-play?] [min number?] [max number?]) (or/c symbol? boolean?)]{ Configures the output buffering range, in seconds. The worker tries to keep the queued audio between the requested lower and upper bounds while decoding. The wrapper normalizes the values before sending the command. A @racket[min] below @racket[1] is raised to @racket[1], and a @racket[min] above @racket[10] is lowered to @racket[10]. A @racket[max] below @racket[min] is changed to @racket[(+ min 1)], and a @racket[max] above @racket[30] is lowered to @racket[30]. The worker side applies its own safe ordering and clamping before using the values. In normal use the return value is @racket['ok].} @deftogether[ (@defproc[(audio-ao-buf-ms! [handle audio-play?] [ms integer?]) (or/c integer? boolean?)] @defproc[(audio-ao-buf-ms [handle audio-play?]) (or/c integer? boolean?)]) ]{ Sets or queries the libao output buffer size, expressed in milliseconds. The @racket[audio-ao-buf-ms!] procedure forwards @racket[ms] to the audio player backend by sending the @racket['ao-buf-ms] RPC command. This hooks into the libao-side buffer configuration and can be used to tune the amount of audio data that the output layer keeps ahead of playback. The @racket[audio-ao-buf-ms] procedure queries the currently configured value by sending the same RPC command without a new value. The returned value is the value reported by the backend. Normally this is an integer number of milliseconds. A boolean result indicates that the value could not be set or queried, or that the backend reported a non-numeric status. Larger buffer values can make playback more robust against short scheduling delays, but also increase latency. Smaller values reduce latency, but may make drop-outs more likely when the decoder or GUI thread is temporarily delayed. The value is clamped between 50 and 1000ms. @racketblock[ (audio-ao-buf-ms! player 500) (audio-ao-buf-ms player) ] } @section{State snapshots} The player keeps a local cache of the most recent state snapshot received from the worker. The cache is updated by an event thread created by @racket[make-audio-player]. The state accessor procedures below read this local cache; they do not synchronously ask the worker for a fresh state. Before the first state event has arrived, most accessors return @racket[#f]. The logical state accessor returns @racket['initialized] for a valid handle whose state hash does not yet contain a @racket['state] entry. If the worker dies or the handle is explicitly invalidated, @racket[audio-state] returns @racket['invalid]. @defproc[(audio-full-state [player audio-play?]) hash?]{ Returns the complete cached state hash. The hash is the payload of the most recent @racket['state] event. It may contain keys such as @racket['state], @racket['file], @racket['duration], @racket['at-second], @racket['at-music-id], @racket['volume], @racket['bits], @racket['rate], @racket['channels], @racket['decoder], @racket['decoder-meta], and @racket['decoder-buf-info].} @defproc[(audio-state [player audio-play?]) symbol?]{ Returns the cached logical player state. Typical values are @racket['initialized], @racket['stopped], @racket['playing], @racket['paused], and @racket['invalid].} @defproc[(audio-at-second [player audio-play?]) (or/c number? boolean?)]{ Returns the cached playback position in seconds, or @racket[#f] when no position is known yet.} @defproc[(audio-duration [player audio-play?]) (or/c number? boolean?)]{ Returns the cached stream duration in seconds, or @racket[#f] when the duration is not known.} @defproc[(audio-file [player audio-play?]) (or/c path-string? boolean?)]{ Returns the cached path of the file currently associated with the active music id, or @racket[#f] when no such file is known.} @defproc[(audio-music-id [player audio-play?]) (or/c number? boolean?)]{ Returns the cached music id used by the asynchronous output side, or @racket[#f] when no output handle is active.} @defproc[(audio-bits [player audio-play?]) (or/c number? boolean?)]{ Returns the cached sample width in bits, or @racket[#f] when the format is not known.} @defproc[(audio-rate [player audio-play?]) (or/c number? boolean?)]{ Returns the cached sample rate, or @racket[#f] when the format is not known.} @defproc[(audio-channels [player audio-play?]) (or/c number? boolean?)]{ Returns the cached channel count, or @racket[#f] when the format is not known.} @defproc[(audio-decoder [player audio-play?]) (or/c symbol? boolean?)]{ Returns the cached decoder kind, or @racket[#f] when no decoder kind is known.} @section{Events and callbacks} The wrapper receives asynchronous events from the worker side. A state event updates the cached state hash and calls @racket[cb-state]. An @racket['audio-done] event calls @racket[cb-eof-stream]. Unknown events are reported through the module's warning mechanism. Callbacks run in the event thread owned by the player handle. They should therefore be quick, should not block for long periods, and should avoid performing complicated UI work directly. A GUI program can use the callbacks to enqueue work onto the GUI eventspace instead. The RPC command path is protected by a mutex in the wrapper. This allows different application threads to call playback procedures on the same handle without interleaving the command and reply parts of a single RPC. @section[#:tag "audio-player-example"]{Example} The following example creates a player, prints state changes, plays a file, and then shuts the player down explicitly. For a larger integration example, see @filepath{play-test.rkt}. The queue variant in that file, selected with @code{(set-test 'queue)}, is documented separately in @filepath{play-test.scrbl}. @codeblock{ #lang racket/base (require "audio-player.rkt") (define player (make-audio-player (lambda (p st) (printf "state: ~a at ~a seconds\n" (hash-ref st 'state #f) (hash-ref st 'at-second #f))) (lambda (p) (printf "decoder reached end of stream\n")))) (audio-play! player "track.flac") ;; Later, for example in response to user input: (audio-pause! player #t) (audio-pause! player #f) (audio-seek! player 50) (audio-volume! player 80) (audio-stop! player) ;; When the player is no longer needed: (audio-quit! player)} For debugging the worker in the same Racket VM, create the player with @racket[#:use-place #f]: @racketblock[ (define debug-player (make-audio-player (lambda (p st) (void)) (lambda (p) (void)) #:use-place #f))] This uses the same command loop and event handling, but starts the worker side in a normal Racket thread instead of a place.