Documentation added

This commit is contained in:
2026-05-16 01:38:40 +02:00
parent c9a91bf2be
commit 17838e4f33
17 changed files with 1746 additions and 394 deletions
-137
View File
@@ -1,137 +0,0 @@
#lang racket/base
(require racket/place
racket/match
"libao.rkt")
(provide ao-placed-player-main)
(define (closed-status)
(hash 'open? #f
'valid? #f
'at-second 0.0
'duration 0.0
'music-id 0
'buf-size 0
'reuse-buf-len 0
'sample-queue-len 0
'volume 100.0
'device-bits 0))
(define (handle-status h)
(if (and h (ao-valid? h))
(hash 'open? #t
'valid? #t
'at-second (ao-at-second h)
'duration (ao-music-duration h)
'music-id (ao-at-music-id h)
'buf-size (ao-bufsize-async h)
'reuse-buf-len (ao-reuse-buf-len-async h)
'sample-queue-len (ao-sample-queue-len-async h)
'volume (ao-volume h)
'device-bits (ao-device-bits h))
(closed-status)))
(define (ao-placed-player-main cmd-ch)
;; First message must provide the log channel.
(define log-ch (place-channel-get cmd-ch))
(define (log! fmt . args)
(place-channel-put log-ch (apply format fmt args)))
(log! "!!! ao-placed-player: started")
(define h #f)
(define (close!)
(when h
(let ((old-h h))
(set! h #f)
(log! "closing ao handle")
(when (ao-valid? old-h)
(ao-close old-h))
(log! "ao handle closed"))))
(place-channel-put cmd-ch 'started)
(let loop ()
(match (place-channel-get cmd-ch)
[`(open-file ,bits ,rate ,channels ,endianness ,wav-output-file)
(log! "!!! open-file bits=~a rate=~a channels=~a endian=~a file=~a"
bits rate channels endianness wav-output-file)
(close!)
(set! h (ao-open-file bits rate channels endianness wav-output-file))
(place-channel-put
cmd-ch
(if (and h (ao-valid? h))
(hash 'ok? #t 'device-bits (ao-device-bits h))
(hash 'ok? #f 'device-bits 0)))
(loop)]
[`(open-live ,bits ,rate ,channels ,endianness)
(log! "!!! open-live bits=~a rate=~a channels=~a endian=~a"
bits rate channels endianness)
(close!)
(set! h (ao-open-live bits rate channels endianness))
(place-channel-put
cmd-ch
(if (and h (ao-valid? h))
(hash 'ok? #t 'device-bits (ao-device-bits h))
(hash 'ok? #f 'device-bits 0)))
(loop)]
[`(play ,music-id ,second ,duration ,buffer ,buf-len ,ao-type)
(when (and h (ao-valid? h))
(ao-play h music-id second duration buffer buf-len ao-type))
(loop)]
[`(clear)
(log! "clear")
(when (and h (ao-valid? h))
(ao-clear-async h))
(loop)]
[`(pause ,paused?)
(log! "pause ~a" paused?)
(when (and h (ao-valid? h))
(ao-pause h paused?))
(loop)]
[`(set-volume ,volume)
(log! "set-volume ~a" volume)
(when (and h (ao-valid? h))
(ao-set-volume! h volume))
(loop)]
[`(status)
(place-channel-put cmd-ch (handle-status h))
(loop)]
[`(valid?)
(place-channel-put cmd-ch (and h (ao-valid? h)))
(loop)]
[`(playback-buf-ms)
(place-channel-put cmd-ch (ao-playback-buf-ms))
(loop)]
[`(set-playback-buf-ms ,ms)
(ao-set-playback-buf-ms! ms)
(place-channel-put cmd-ch 'ok)
(loop)]
[`(close)
(log! "!!! close received")
(close!)
(place-channel-put cmd-ch 'closed)]
[`(stop)
(log! "!!! stop received")
(close!)
(place-channel-put cmd-ch 'stopped)
(loop)]
[msg
(log! "unknown message: ~a" msg)
(loop)])))
-229
View File
@@ -1,229 +0,0 @@
#lang racket/base
(require racket/place
racket/runtime-path
"private/utils.rkt"
"libao.rkt")
(provide make-ao-player
ao-player?
ao-player-open-file!
ao-player-open-live!
ao-player-play!
ao-player-close!
ao-player-stop!
ao-player-clear!
ao-player-pause!
ao-player-set-volume!
ao-player-volume
ao-player-status
ao-player-at-second
ao-player-music-duration
ao-player-at-music-id
ao-player-bufsize-async
ao-player-reuse-buf-len-async
ao-player-sample-queue-len-async
ao-player-device-bits
ao-player-valid?
ao-player-playback-buf-ms
ao-player-set-playback-buf-ms!
ao-player-audio-callback
ao-valid-bits?
ao-valid-rate?
ao-valid-channels?
ao-valid-format?
ao-supported-music-format?)
(define-runtime-path placed-player-module "ao-placed-player.rkt")
(struct ao-player
(cmd-ch
log-ch
current-bits
current-rate
current-channels
current-endianness
wav-output-file
device-bits)
#:mutable
#:transparent)
(define (default-log-handler msg)
(dbg-sound msg))
(define (start-log-reader! log-ch log-handler)
(thread
(lambda ()
(let loop ()
(define msg (place-channel-get log-ch))
(log-handler msg)
(loop)))))
(define (make-ao-player #:wav-output-file [wav-output-file #f]
#:log-handler [log-handler default-log-handler])
(let ((cmd-ch (dynamic-place placed-player-module 'ao-placed-player-main)))
(let-values (((log-main-ch log-place-ch) (place-channel)))
;; Geef het place-einde van het log-channel aan de worker.
(place-channel-put cmd-ch log-place-ch)
;; Main-kant leest logs.
(start-log-reader! log-main-ch log-handler)
;; Startup handshake via command-channel.
(define started (place-channel-get cmd-ch))
(unless (eq? started 'started)
(error 'make-ao-player "ao place did not start: ~a" started))
(ao-player cmd-ch log-main-ch
-1 -1 -1
'native-endian
wav-output-file
0))))
(define (send! player msg)
(place-channel-put (ao-player-cmd-ch player) msg))
(define (call! player msg)
(define cmd-ch (ao-player-cmd-ch player))
(place-channel-put cmd-ch msg)
(place-channel-get cmd-ch))
(define (reset-format-cache! player)
(set-ao-player-current-bits! player -1)
(set-ao-player-current-rate! player -1)
(set-ao-player-current-channels! player -1)
(set-ao-player-current-endianness! player 'native-endian)
(set-ao-player-device-bits! player 0))
(define (same-format? player bits rate channels endianness)
(and (= (ao-player-current-bits player) bits)
(= (ao-player-current-rate player) rate)
(= (ao-player-current-channels player) channels)
(eq? (ao-player-current-endianness player) endianness)))
(define (remember-format! player bits rate channels endianness reply)
(set-ao-player-current-bits! player bits)
(set-ao-player-current-rate! player rate)
(set-ao-player-current-channels! player channels)
(set-ao-player-current-endianness! player endianness)
(set-ao-player-device-bits! player (hash-ref reply 'device-bits 0)))
(define (ao-player-open-file! player bits rate channels
#:endianness [endianness 'native-endian]
#:wav-output-file [wav-output-file
(ao-player-wav-output-file player)])
(cond
[(same-format? player bits rate channels endianness) #t]
[else
(define reply
(call! player `(open-file ,bits ,rate ,channels
,endianness ,wav-output-file)))
(cond
[(hash-ref reply 'ok? #f)
(remember-format! player bits rate channels endianness reply)
#t]
[else
(reset-format-cache! player)
#f])]))
(define (ao-player-open-live! player bits rate channels
#:endianness [endianness 'native-endian])
(cond
[(same-format? player bits rate channels endianness) #t]
[else
(define reply
(call! player `(open-live ,bits ,rate ,channels ,endianness)))
(cond
[(hash-ref reply 'ok? #f)
(remember-format! player bits rate channels endianness reply)
#t]
[else
(reset-format-cache! player)
#f])]))
(define (ao-player-open-for-info! player buf-info)
(define bits (hash-ref buf-info 'bits-per-sample))
(define rate (hash-ref buf-info 'sample-rate))
(define channels (hash-ref buf-info 'channels))
(define endianness (hash-ref buf-info 'endianness 'native-endian))
(if (ao-player-wav-output-file player)
(ao-player-open-file! player bits rate channels #:endianness endianness)
(ao-player-open-live! player bits rate channels #:endianness endianness)))
(define (ao-player-play! player music-id second duration
buf-info buffer buf-len ao-type)
;; This intentionally synchronizes on open/reopen. If opening fails or
;; hangs, the caller sees it immediately.
(when (ao-player-open-for-info! player buf-info)
(send! player `(play ,music-id ,second ,duration
,buffer ,buf-len ,ao-type))))
(define (ao-player-clear! player)
(send! player '(clear)))
(define (ao-player-pause! player paused?)
(send! player `(pause ,paused?)))
(define (ao-player-set-volume! player volume)
(send! player `(set-volume ,volume)))
(define (ao-player-status player)
(call! player '(status)))
(define (status-ref player key fallback)
(hash-ref (ao-player-status player) key fallback))
(define (ao-player-at-second player)
(status-ref player 'at-second 0.0))
(define (ao-player-music-duration player)
(status-ref player 'duration 0.0))
(define (ao-player-at-music-id player)
(status-ref player 'music-id 0))
(define (ao-player-bufsize-async player)
(status-ref player 'buf-size 0))
(define (ao-player-reuse-buf-len-async player)
(status-ref player 'reuse-buf-len 0))
(define (ao-player-sample-queue-len-async player)
(status-ref player 'sample-queue-len 0))
(define (ao-player-volume player)
(status-ref player 'volume 100.0))
(define (ao-player-valid? player)
(call! player '(valid?)))
(define (ao-player-close! player)
(define r (call! player '(close)))
(reset-format-cache! player)
r)
(define (ao-player-stop! player)
(define r (call! player '(stop)))
(reset-format-cache! player)
r)
(define (ao-player-playback-buf-ms player)
(call! player '(playback-buf-ms)))
(define (ao-player-set-playback-buf-ms! player ms)
(call! player `(set-playback-buf-ms ,ms)))
(define (ao-player-audio-callback player current-music-id)
(lambda (type ao-type handle buf-info buffer buf-len)
(define sample (hash-ref buf-info 'sample 0))
(define rate (hash-ref buf-info 'sample-rate 44100))
(define second (/ (exact->inexact sample) (exact->inexact rate)))
(define duration (hash-ref buf-info 'duration 0.0))
(ao-player-play! player
(current-music-id)
second
duration
buf-info
buffer
buf-len
ao-type)))
+343 -13
View File
File diff suppressed because it is too large Load Diff
+25 -12
View File
@@ -8,18 +8,31 @@
(define scribblings (define scribblings
'( '(
("scrbl/libao.scrbl" () (library)) ;; Main package overview.
("scrbl/audio-decoder.scrbl" () (library)) ;; The negative sort number makes this document appear before the
("scrbl/flac-decoder.scrbl" () (library)) ;; other racket-audio manuals in the library documentation listing.
("scrbl/mp3-decoder.scrbl" () (library)) ("scrbl/racket-audio.scrbl" () (library -100))
("scrbl/audio-sniffer.scrbl" () (library))
("scrbl/ffmpeg-ffi.scrbl" () (library)) ;; High-level user-facing APIs.
("scrbl/ffmpeg-decoder.scrbl" () (library)) ("scrbl/audio-player.scrbl" () (library -90))
("scrbl/ffmpeg-c-backend.scrbl" () (library)) ("scrbl/taglib.scrbl" () (library -80))
("scrbl/ffmpeg-definitions.scrbl" () (library)) ("scrbl/play-test.scrbl" () (library -70))
("scrbl/libao-async-ffi-racket.scrbl" () (library))
) ;; Format detection and decoder layer.
) ("scrbl/audio-sniffer.scrbl" () (library -60))
("scrbl/audio-decoder.scrbl" () (library -50))
("scrbl/mp3-decoder.scrbl" () (library -40))
("scrbl/flac-decoder.scrbl" () (library -30))
("scrbl/ffmpeg-decoder.scrbl" () (library -20))
;; Lower-level playback and FFI modules.
("scrbl/audio-placed-player.scrbl" () (library 10))
("scrbl/libao.scrbl" () (library 20))
("scrbl/libao-async-ffi-racket.scrbl" () (library 30))
("scrbl/ffmpeg-c-backend.scrbl" () (library 40))
("scrbl/ffmpeg-ffi.scrbl" () (library 50))
("scrbl/ffmpeg-definitions.scrbl" () (library 60))
))
(define deps (define deps
'("racket/gui" "racket/base" "racket" '("racket/gui" "racket/base" "racket"
+1 -1
View File
@@ -9,7 +9,7 @@
"tests.rkt" "tests.rkt"
) )
(define place-mode #f) (define place-mode #t)
(define run-queue #f) (define run-queue #f)
(define (set-test a) (define (set-test a)
+290
View File
@@ -0,0 +1,290 @@
#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.
+275
View File
@@ -0,0 +1,275 @@
#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 state-hash)]
where @racket[player] is the player handle 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 @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.
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?])
symbol?]{
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['ok].
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].}
@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{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.
@@ -0,0 +1,91 @@
@startuml
!theme plain
hide empty description
top to bottom direction
title Placed audio player - public state model
skinparam backgroundColor transparent
skinparam shadowing false
skinparam roundcorner 14
skinparam ArrowThickness 1.2
skinparam DefaultFontName "DejaVu Sans"
skinparam DefaultFontSize 13
skinparam state {
BackgroundColor #F8F8F8
BorderColor #555555
FontColor #222222
StartColor #555555
EndColor #555555
BackgroundColor<<blue>> #DCEEFF
BorderColor<<blue>> #3A7FC4
FontColor<<blue>> #123A63
BackgroundColor<<green>> #E3F6E3
BorderColor<<green>> #3C9D40
FontColor<<green>> #1E5C22
BackgroundColor<<purple>> #F0E6FF
BorderColor<<purple>> #8A5CC2
FontColor<<purple>> #4B2A73
BackgroundColor<<orange>> #FFE8CC
BorderColor<<orange>> #D8842A
FontColor<<orange>> #7A4A12
BackgroundColor<<red>> #FFE5E5
BorderColor<<red>> #CC3333
FontColor<<red>> #7A1F1F
}
state NotInitialized <<purple>>
state FatalError <<red>>
state Terminated <<purple>>
[*] -[#8A5CC2]-> NotInitialized
NotInitialized -[#3A7FC4]-> Initialized : init
NotInitialized -[#CC3333]-> FatalError : command before init
state Initialized {
[*] -[#3A7FC4]-> Stopped
state Stopped <<blue>> {
Stopped : volume(p) / set volume
Stopped : stop / ignore
}
state Playing <<orange>> {
Playing : seek(p) / seek decoder
Playing : volume(p) / set volume
}
state Paused <<green>> {
Paused : seek(p) / seek decoder
Paused : volume(p) / set volume
}
Stopped -[#D8842A]-> Playing : open(file)
Playing -[#3C9D40]-> Paused : pause #t
Paused -[#D8842A]-> Playing : pause #f
Playing -[#3A7FC4]-> Stopped : audio done
Playing -[#3A7FC4]-> Stopped : stop
Paused -[#3A7FC4]-> Stopped : stop
Playing -[#D8842A]-> Playing : open(new-file)
Playing -[#3A7FC4]-> Stopped : worker exception
Paused -[#3A7FC4]-> Stopped : worker exception
}
Initialized -[#8A5CC2]-> Terminated : quit / cleanup
FatalError -[#555555]-> [*]
Terminated -[#555555]-> [*]
@enduml
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 22 KiB

+206
View File
@@ -0,0 +1,206 @@
#lang scribble/manual
@(require (for-label racket/base
racket/path
early-return
simple-log
"../audio-player.rkt"))
@title{Playback Test Program}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@defmodule[racket-audio/play-test]
The @racketmodname[racket-audio/play-test.rkt] module is a small integration test and
usage example for @racketmodname[racket-audio/audio-player]. It is not the public
playback API itself; normal applications should use @racketmodname[racket-audio/audio-player]
directly. This module shows how a program can create an audio player, observe
state updates, react to end-of-stream events, and use the EOF callback to drive
a simple playback queue.
The test is intentionally close to the way an application would use the high
level player API. It creates one player handle with @racket[make-audio-player],
prints compact progress information from the state callback, and starts the
next file from the EOF callback when queue mode is enabled.
@section{Purpose}
The test exercises three parts of the player wrapper:
@itemlist[#:style 'compact
@item{state callback handling, including cached position, duration, buffer
size, volume, and logical player state;}
@item{EOF callback handling, including starting another file after the
current stream has reached decoder end-of-stream;}
@item{place based playback through @racket[make-audio-player]'s
@racket[#:use-place] argument.}]
The file depends on @filepath{tests.rkt} for the concrete test files, such as
@racket[test-file2], @racket[test-file3], and @racket[test-file4]. The test
therefore documents the integration pattern rather than a portable standalone
program.
@section{Selecting the test mode}
The module contains a small mode variable:
@racketblock[
(define run-queue #f)
(define (set-test a)
(set! run-queue a))]
When @racket[run-queue] is @racket['queue], the EOF callback consumes files
from @racket[play-queue]. When it is @racket['once], the first EOF callback
starts @racket[test-file3] once and then disables that mode. With the default
@racket[#f] value, the final kickoff call does not start playback.
For queue playback, select queue mode before the kickoff call:
@racketblock[
(set-test 'queue)]
In the current test file the kickoff is performed by calling the EOF callback
manually at the end of the module. That is a convenient test idiom: the same
callback that advances the queue after a stream has finished is also reused to
start the first stream.
@section{Queue setup}
The queue itself is a simple list of path values supplied by
@filepath{tests.rkt}:
@racketblock[
(define play-queue (list test-file2 test-file3 test-file4))]
The queue is destructive in the ordinary Racket sense: each successful EOF
advance starts @racket[(car play-queue)] and then updates @racket[play-queue]
to @racket[(cdr play-queue)]. When the queue is empty, the callback shuts the
player down with @racket[audio-quit!].
@section{Formatting state output}
The helper @racket[to-time-str] turns a second count into a compact
@tt{mm:ss} string:
@racketblock[
(define (to-time-str s*)
(let* ((s (round s*))
(minutes (quotient s 60))
(seconds (remainder s 60)))
(sprintf "%02d:%02d" minutes seconds)))]
The state callback uses this helper to print progress lines that are easier to
read than raw seconds.
@section{State callback}
The state callback has the shape expected by @racket[make-audio-player]:
@racketblock[
(define (audio-player-state h st)
...)]
The first argument is the player handle and the second argument is the state
hash received from the worker side. The callback begins with an
@racket[early-return] guard:
@racketblock[
(early-return
((? (not (audio-play? h)) => 'done))
...)]
This avoids using a handle after it has been invalidated, for example after
@racket[audio-quit!]. The rest of the callback reads the current file name,
position, duration, logical player state, volume, buffer size, and diagnostic
message. It prints at most one line per rounded second by comparing the
current second with @racket[current-sec].
The output line is deliberately compact. It contains the current file name,
music id, playback time, duration, logical state, volume, buffer size, and the
message stored in the state hash.
@section{EOF callback and queue advancement}
The EOF callback is where the queue behaviour is implemented:
@racketblock[
(define (audio-player-eof h)
(dbg-sound "audio-player-eof called")
(when (eq? run-queue 'queue)
(if (null? play-queue)
(audio-quit! h)
(begin
(audio-play! h (car play-queue))
(set! play-queue (cdr play-queue))))))]
In queue mode, an empty queue means that playback is finished and the player is
closed with @racket[audio-quit!]. Otherwise the next file is started with
@racket[audio-play!] and removed from the queue.
The same callback also contains a small @racket['once] mode:
@racketblock[
(when (eq? run-queue 'once)
(set! run-queue #f)
(audio-play! h test-file3))]
That mode is useful when testing a single explicit transition from an EOF event
to a new file.
@section{Creating the player}
The test creates the player with the two callbacks and an explicit place-mode
flag:
@racketblock[
(define place-mode #t)
(define h
(make-audio-player audio-player-state
audio-player-eof
#:use-place place-mode))]
With @racket[place-mode] set to @racket[#t], the player runs the playback side
in a separate place. This is the normal robustness mode for audio playback,
because the decoder and audio feeder run in a separate Racket VM. Setting
@racket[place-mode] to @racket[#f] runs the same command loop in a Racket
thread with ordinary asynchronous channels, which can be easier to debug from
DrRacket.
@section{Starting the test}
At the end of the module, logging is sent to the display and the EOF callback
is called once by hand:
@racketblock[
(sl-log-to-display)
(audio-player-eof h)]
Calling @racket[audio-player-eof] manually may look unusual, but it keeps the
queue logic in one place. The first call starts the first queued file; later
calls are made by the player wrapper when the decoder reports end-of-stream.
A typical queue test therefore looks like this in the source:
@racketblock[
(set-test 'queue)
(sl-log-to-display)
(audio-player-eof h)]
@section{Integration pattern}
The important pattern for an application is not the global variables in the
test file, but the division of responsibility:
@itemlist[#:style 'compact
@item{create one player with @racket[make-audio-player];}
@item{keep display or application state in the state callback;}
@item{keep queue advancement in the EOF callback;}
@item{use @racket[audio-play!] to start the next file;}
@item{use @racket[audio-quit!] when the queue is exhausted.}]
An application will usually wrap the queue in its own data structure instead of
using a top-level mutable list, but the control flow is the same.
+123
View File
@@ -0,0 +1,123 @@
@startuml
!theme plain
hide empty description
title Placed audio player - command loop and worker detail
skinparam backgroundColor transparent
skinparam shadowing false
skinparam roundcorner 14
skinparam ArrowThickness 1.2
skinparam DefaultFontName "DejaVu Sans"
skinparam DefaultFontSize 13
skinparam state {
BackgroundColor #F8F8F8
BorderColor #555555
FontColor #222222
StartColor #555555
EndColor #555555
BackgroundColor<<blue>> #DCEEFF
BorderColor<<blue>> #3A7FC4
FontColor<<blue>> #123A63
BackgroundColor<<green>> #E3F6E3
BorderColor<<green>> #3C9D40
FontColor<<green>> #1E5C22
BackgroundColor<<orange>> #FFE8CC
BorderColor<<orange>> #D8842A
FontColor<<orange>> #7A4A12
BackgroundColor<<red>> #FFE5E5
BorderColor<<red>> #CC3333
FontColor<<red>> #7A1F1F
BackgroundColor<<gray>> #F2F2F2
BorderColor<<gray>> #888888
FontColor<<gray>> #444444
}
state "Command loop:\nreplace active worker" as A <<gray>> {
[*] -down-> CurrentWorkerActive
state "Current worker active" as CurrentWorkerActive <<orange>>
state "Interrupt requested" as InterruptRequested <<green>>
state "Waiting for worker" as WaitingForWorker <<green>>
state "Starting new worker" as StartingNewWorker <<orange>>
state "New worker active" as NewWorkerActive <<orange>>
InterruptRequested : entry / feed-interrupted := #t
InterruptRequested : entry / ao-clear-async
InterruptRequested : entry / player-state := stopped
InterruptRequested : entry / audio-stop
WaitingForWorker : do / wait until feeding-audio = #f
StartingNewWorker : entry / thread-wait
StartingNewWorker : entry / audio-open
StartingNewWorker : entry / player-state := playing
StartingNewWorker : entry / spawn worker
CurrentWorkerActive -down-> InterruptRequested : open(new)
InterruptRequested -down-> WaitingForWorker
WaitingForWorker -down-> StartingNewWorker : ready
StartingNewWorker -down-> NewWorkerActive
NewWorkerActive -down-> [*]
}
state "Worker thread lifecycle" as B <<gray>> {
[*] -down-> WorkerIdle
state "WorkerIdle" as WorkerIdle <<blue>>
state "WorkerExited" as WorkerExited <<blue>>
state "WorkerFailed" as WorkerFailed <<red>>
WorkerIdle -down-> C : open(file)\n/ audio-open\nspawn worker
state "Worker active" as C <<orange>> {
C : entry / feeding-audio := #t
C : exit / feeding-audio := #f
[*] -right-> Reading
state "Reading" as Reading <<orange>>
state "DrainingAO" as DrainingAO <<green>>
state "MarkStopped" as MarkStopped <<blue>>
state "WorkerDone" as WorkerDone <<blue>>
Reading : do / audio-read
Reading : pause #t / ao-pause #t and wait
Reading : pause #f / ao-pause #f
DrainingAO : do / wait until AO queue drains
DrainingAO : pause #t / ao-pause #t
DrainingAO : pause #f / ao-pause #f
Reading -right-> DrainingAO : audio-read returns\n[not feed-interrupted]\n/ emit audio-done
DrainingAO -right-> MarkStopped : [AO queue empty\nand file-id is current]
MarkStopped -right-> WorkerDone : / player-state := stopped
WorkerDone -right-> [*]
Reading -down-> WorkerDone : [feed-interrupted]\n/ feed-interrupted := #f
DrainingAO -down-> WorkerDone : [AO closed,\nqueue grows,\nor old file-id]
note bottom of DrainingAO
The file-id check prevents an old worker
from stopping a newly opened file.
end note
}
C -down-> WorkerExited : worker exits
C -down-> WorkerFailed : exception\n/ emit exception\nplayer-state := stopped
WorkerExited -down-> [*]
WorkerFailed -down-> [*]
}
A -[hidden]right-> B
@enduml
+96
View File
@@ -0,0 +1,96 @@
#lang scribble/manual
@(require racket/runtime-path
scribble/core
scribble/html-properties
(for-label racket/base
racket/path
"../audio-player.rkt"
"../taglib.rkt"
"../audio-sniffer.rkt"
"../audio-decoder.rkt"
"../audio-placed-player.rkt"
"../libao.rkt"
"../mp3-decoder.rkt"
"../flac-decoder.rkt"
"../ffmpeg-decoder.rkt"))
@(define-runtime-path rktplayer-logo "rktplayer.svg")
@(define title-logo-style
(style #f
(list (attributes
'((style . "float: right; margin-left: 1.5em; margin-bottom: 0.5em;"))))))
@elem[#:style title-logo-style]{@image[#:scale 0.25 rktplayer-logo]}
@title{@elem{racket-audio}}
@;;title{racket-audio}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@racketmodname[racket-audio] is a small audio playback toolkit for Racket. It
combines high-level asynchronous playback, optional metadata reading, file type
sniffing, decoder backends, and libao based output. Most applications should
start with the high-level player API and only use the lower-level modules when
they need to add a decoder, inspect the playback pipeline, or debug the native
FFI boundary.
@section{APIs for normal users}
For ordinary playback, use @racketmodname[racket-audio/audio-player]. It
creates an audio player, starts the worker side, and exposes procedures such as
@racket[make-audio-player], @racket[audio-play!], @racket[audio-pause!],
@racket[audio-stop!], @racket[audio-seek!], and @racket[audio-quit!]. The
player is asynchronous: commands return after they have been accepted, while
state updates and end-of-stream notifications are delivered through callbacks.
Use @racketmodname[racket-audio/taglib] when an application needs metadata such
as title, artist, album, duration-related properties, generic TagLib properties,
or embedded cover art. The module reads metadata into a Racket-side snapshot
and does not keep the native TagLib file handle open.
Use @racketmodname[racket-audio/audio-sniffer] when file type detection should
be based on file contents rather than only on extensions. The sniffer is useful
before choosing a decoder, validating input, or presenting a likely media type
to the user.
The @racketmodname[racket-audio/play-test] module is not a library API, but it
is a useful integration example. In particular, its queue mode, selected with
@racket[(set-test 'queue)], shows how an EOF callback can start the next item
in a simple playback queue.
@section{Lower-level modules for geeks}
The modules below are normally used by the player implementation rather than by
application code. They are documented because they are useful when extending,
debugging, or replacing parts of the pipeline.
@itemlist[#:style 'compact
@item{@racketmodname[racket-audio/audio-placed-player] implements the worker
side of the high-level player. It is normally run in a Racket place, so
the timing-sensitive audio feeder runs in a separate VM, but it can also
be run in a thread with async channels for easier debugging.}
@item{@racketmodname[racket-audio/audio-decoder] provides the decoder registry
and a uniform open/read/seek/stop interface over concrete decoder
backends.}
@item{@racketmodname[racket-audio/mp3-decoder],
@racketmodname[racket-audio/flac-decoder], and
@racketmodname[racket-audio/ffmpeg-decoder] are concrete decoder
frontends. The FFmpeg path is the general-purpose fallback for formats
not handled by the specialised decoders.}
@item{@racketmodname[racket-audio/libao] and
@racketmodname[racket-audio/libao-async-ffi-racket] form the output side:
they open the native audio device, queue PCM buffers, apply volume, and
feed libao asynchronously.}
@item{@racketmodname[racket-audio/ffmpeg-ffi],
@racketmodname[racket-audio/ffmpeg-definitions], and the FFmpeg C backend
documentation describe the native FFmpeg boundary, the direct FFI
definitions, version-sensitive structures, and the fixed PCM format used
by the decoder pipeline.}]
In short: applications should usually combine
@racketmodname[racket-audio/audio-player] with @racketmodname[racket-audio/taglib].
The other modules document the machinery underneath: format detection, decoder
selection, place-based playback, buffering, native output, and FFmpeg access.
+73
View File
@@ -0,0 +1,73 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Generator: Adobe Illustrator 15.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg
version="1.1"
id="circle_pieces"
x="0px"
y="0px"
width="700"
height="700"
viewBox="0 0 700 699.99999"
enable-background="new 0 0 511.875 511.824"
xml:space="preserve"
sodipodi:docname="rktplayer.svg"
inkscape:version="1.4.3 (0d15f75042, 2025-12-25)"
inkscape:export-filename="rktplayer.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"><defs
id="defs1" /><sodipodi:namedview
id="namedview1"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.25"
inkscape:showpageshadow="2"
inkscape:pageopacity="0.0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d1d1d1"
inkscape:zoom="2.6152606"
inkscape:cx="442.21215"
inkscape:cy="327.30964"
inkscape:window-width="3840"
inkscape:window-height="2088"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="1"
inkscape:current-layer="g2" />
<g
id="g2"><ellipse
style="fill:#ffffff;stroke:none;stroke-width:12.2336"
id="path5"
cx="340.64243"
cy="411.03333"
rx="224.86732"
ry="244.94084" /><path
id="blue-piece"
fill="#3e5ba9"
d="m 517.53252,561.05183 c 29.98772,-41.38652 47.87279,-93.56675 47.87279,-150.27611 0,-134.46841 -100.55629,-243.4773 -224.59995,-243.4773 -26.98291,0 -52.853,5.16309 -76.82046,14.61962 91.18247,51.85123 211.79537,221.0202 253.54762,379.13379 z"
style="stroke-width:0.923962" /><path
id="left-red-piece"
fill="#9f1d20"
d="m 308.63814,322.60871 c -35.03626,-40.91705 -74.27268,-73.41269 -115.82881,-94.96454 -46.95076,44.62655 -76.60304,110.12097 -76.60304,183.13155 0,61.38953 20.9662,117.46493 55.54987,160.29253 30.48734,-99.2955 87.80229,-195.00079 136.88198,-248.45954 z"
style="stroke-width:0.923962" /><path
id="bottom-red-piece"
fill="#9f1d20"
d="M 350.023,377.81831 C 301.39945,434.6258 252.94628,534.06945 235.41085,625.818 c 31.43155,18.14057 67.30198,28.43598 105.3954,28.43598 39.16364,0 75.97918,-10.87646 108.03459,-29.97904 C 430.36385,531.72504 395.48556,446.9645 350.023,377.81831 Z"
style="stroke-width:0.923962" /><path
d="m 130.9,323.08501 c 19.13656,-1.81007 36.94048,13.59009 40.79155,36.14648 l 27.27181,159.74052 c 4.10781,24.06017 -9.2145,47.15353 -29.75784,51.57967 -20.54206,4.42609 -46.83685,-11.17988 -50.94473,-35.24153 L 90.989079,375.57159 c -4.107817,-24.06018 15.527281,-47.46572 36.069341,-51.8918 1.28388,-0.27664 2.5658,-0.47409 3.84158,-0.59478 z"
style="stroke-width:3;stroke-linejoin:round;fill:#999999;stroke:#000000;stroke-opacity:1;stroke-dasharray:none"
id="path12" /><path
d="m 564.07865,327.18539 c -19.13791,-1.79576 -36.9303,13.61771 -40.76451,36.17698 l -27.15231,159.76087 c -4.08982,24.06324 9.24977,47.14663 29.7964,51.5574 20.54537,4.41072 46.82848,-11.2149 50.91837,-35.27963 L 604.02882,379.6421 c 4.08982,-24.06324 -15.56278,-47.45409 -36.10814,-51.86481 -1.28409,-0.27567 -2.56616,-0.47217 -3.84203,-0.5919 z"
style="stroke-width:3;stroke-linejoin:round;fill:#999999;stroke:#000000;stroke-opacity:1;stroke-dasharray:none"
id="path12-5" /><path
d="m 343.63672,38.474609 c 0,0 -324.531251,-6.13e-4 -324.531251,295.406251 0,132.9331 34.615466,177.24529 51.923828,192.01562 0,0 51.925783,29.54031 34.619143,-11.07812 0,-34.17562 -17.310549,-107.08399 -17.310549,-107.08399 0,0 -17.308594,-0.002 -17.308594,-73.85351 0,-88.62206 69.234883,-243.708985 259.626953,-243.708985 h 34.61523 c 190.39209,0 259.62696,155.086925 259.62696,243.708985 0,73.85171 -17.3086,73.85351 -17.3086,73.85351 0,0 -17.31054,72.90837 -17.31054,107.08399 -17.30663,40.61843 34.61914,11.07812 34.61914,11.07812 17.30837,-14.77033 51.92383,-59.08252 51.92383,-192.01562 0,-295.406864 -324.53125,-295.406251 -324.53125,-295.406251 z"
style="stroke-width:3;stroke-linejoin:round;fill:#cccccc;stroke:#000000;stroke-opacity:1;stroke-dasharray:none"
id="path11" /></g></svg>

After

Width:  |  Height:  |  Size: 4.2 KiB

+219
View File
@@ -0,0 +1,219 @@
#lang scribble/manual
@(require (for-label racket/base
racket/contract
racket/path
racket/draw
"../taglib.rkt"))
@title{TagLib Metadata}
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
@defmodule[racket-audio/taglib]
The @racketmodname[racket-audio/taglib] module provides the high level metadata
reader used by the audio package. It wraps the lower level TagLib FFI module
and presents a small, read-only Racket API for common tags, audio properties,
generic properties, and embedded cover art.
Calling @racket[id3-tags] opens the file through TagLib, copies the values that
are needed on the Racket side, reads the optional embedded picture, frees the
native TagLib objects, and returns an opaque tag handle. The handle is
therefore a snapshot of the metadata at the time it was read. It does not keep
the media file or the native TagLib handle open.
The name @racket[id3-tags] is historical. The module uses TagLib to open the
file, so the usable file types are the file types supported by the TagLib
library available at run time. This module is not a tag editor; it only reads
metadata.
@section{Reading metadata}
@defproc[(id3-tags [file path-string?]) any/c]{
Reads metadata from @racket[file] and returns an opaque tag handle. The
argument may be a path or a string. On Windows, the implementation retries
with the wide-character TagLib open function when the normal open function does
not produce a valid TagLib file.
The returned handle is passed to the other procedures in this module. If the
file cannot be opened, @racket[id3-tags] still returns a handle, but
@racket[tags-valid?] returns @racket[#f]. Other accessors then return their
default values, such as @racket[""], @racket[-1], @racket['()], or
@racket[#f].}
@defproc[(tags-valid? [tags any/c]) boolean?]{
Returns @racket[#t] when @racket[id3-tags] successfully opened the file and
TagLib reported it as valid.}
@racketblock[
(define tags (id3-tags "song.mp3"))
(when (tags-valid? tags)
(printf "~a - ~a\n" (tags-artist tags) (tags-title tags)))]
@section{Common tag fields}
@deftogether[
(@defproc[(tags-title [tags any/c]) string?]
@defproc[(tags-album [tags any/c]) string?]
@defproc[(tags-artist [tags any/c]) string?]
@defproc[(tags-comment [tags any/c]) string?]
@defproc[(tags-genre [tags any/c]) string?])]
Return the common textual fields from the TagLib tag interface. Missing fields
are returned as the empty string.
@deftogether[
(@defproc[(tags-year [tags any/c]) integer?]
@defproc[(tags-track [tags any/c]) integer?])]
Return the year and track number from the common TagLib tag interface. Missing
numeric values are returned as @racket[-1].
@deftogether[
(@defproc[(tags-composer [tags any/c])
(or/c string? (listof string?))]
@defproc[(tags-album-artist [tags any/c])
(or/c string? (listof string?))]
@defproc[(tags-disc-number [tags any/c])
(or/c number? #f)])]
Return selected values from the generic TagLib property store. The composer is
read from the lower-case @racket['composer] key, the album artist from
@racket['albumartist], and the disc number from @racket['discnumber].
Composer and album artist return a list of strings when the property is present
and the empty string when it is missing. The disc number is parsed from the
first property value and defaults to @racket[-1]. If the stored value cannot be
parsed as a number, the result may be @racket[#f]. Use @racket[tags-keys] and
@racket[tags-ref] for direct access to the complete generic property store.
@section{Audio properties}
@deftogether[
(@defproc[(tags-length [tags any/c]) integer?]
@defproc[(tags-sample-rate [tags any/c]) integer?]
@defproc[(tags-bit-rate [tags any/c]) integer?]
@defproc[(tags-channels [tags any/c]) integer?])]
Return audio properties reported by TagLib: length in seconds, sample rate in
Hz, bit rate in kbit/s, and number of channels. Missing values are returned as
@racket[-1].
@section{Generic properties}
@defproc[(tags-keys [tags any/c]) (listof symbol?)]{
Returns the generic TagLib property keys found in the file. Keys are
lower-cased and converted to symbols.}
@defproc[(tags-ref [tags any/c] [key symbol?])
(or/c (listof string?) #f)]{
Returns the list of values associated with @racket[key], or @racket[#f] when the
property was not found. Use lower-case symbol keys, matching the values
returned by @racket[tags-keys].}
@racketblock[
(for ([key (in-list (tags-keys tags))])
(printf "~a: ~s\n" key (tags-ref tags key)))]
Generic properties may contain multiple values for a single key. The API keeps
those values as lists instead of joining them into one string.
@section{Embedded pictures}
The module represents embedded artwork as an opaque @deftech{picture value}.
The picture value is returned by @racket[tags-picture] and can be inspected with
the picture procedures documented below. When no picture is available, the
picture-related procedures return @racket[#f].
@defproc[(tags-picture [tags any/c]) (or/c any/c #f)]{
Returns the embedded picture value, or @racket[#f] when the file has no picture
that the underlying FFI layer could read.}
@deftogether[
(@defproc[(tags-picture->kind [tags any/c]) (or/c integer? #f)]
@defproc[(tags-picture->mimetype [tags any/c]) (or/c string? #f)]
@defproc[(tags-picture->size [tags any/c]) (or/c integer? #f)]
@defproc[(tags-picture->ext [tags any/c]) (or/c symbol? #f)])]
Return selected information about the embedded picture. The kind is the
numeric picture type reported by the FFI layer. The MIME type is the stored
MIME type, such as @racket["image/jpeg"] or @racket["image/png"]. The size is
the number of bytes in the embedded image. The extension helper returns
@racket['jpg], @racket['png], or @racket[#f] when the MIME type is not
recognized.
@defproc[(tags-picture->bitmap [tags any/c])
(or/c (is-a?/c bitmap%) #f)]{
Reads the embedded picture bytes with @racket[read-bitmap] and returns a
@racket[bitmap%] object. If there is no embedded picture, the result is
@racket[#f].}
@defproc[(tags-picture->file [tags any/c]
[path path-string?])
boolean?]{
Writes the embedded picture bytes to @racket[path] in binary mode, replacing an
existing file. The procedure returns @racket[#t] when a picture was written and
@racket[#f] when the tag handle has no picture. The file name is not adjusted
automatically; use @racket[tags-picture->ext] when the caller wants to choose an
extension from the MIME type.}
@racketblock[
(define ext (tags-picture->ext tags))
(when ext
(tags-picture->file tags
(format "cover.~a" ext)))]
@section{Picture values}
@deftogether[
(@defproc[(id3-picture-mimetype [picture any/c]) string?]
@defproc[(id3-picture-kind [picture any/c]) integer?]
@defproc[(id3-picture-size [picture any/c]) integer?]
@defproc[(id3-picture-bytes [picture any/c]) bytes?])]
Access the fields of a picture value returned by @racket[tags-picture]. These
procedures are useful when the caller wants to process the image bytes directly
instead of converting them to a bitmap or writing them to a file.
@section{Converting to a hash}
@defproc[(tags->hash [tags any/c]) hash?]{
Returns a mutable hash containing the core values copied from the tag handle.
The hash contains the keys @racket['valid?], @racket['title], @racket['album],
@racket['artist], @racket['comment], @racket['composer], @racket['genre],
@racket['year], @racket['track], @racket['length], @racket['sample-rate],
@racket['bit-rate], @racket['channels], @racket['picture], and @racket['keys].
The hash is intended as a convenient snapshot for application code. Generic
property values are not expanded into the hash; use @racket[tags-ref] for those
values.}
@section{Example}
@racketblock[
(define tags (id3-tags "track.flac"))
(cond
[(not (tags-valid? tags))
(printf "No readable tags\n")]
[else
(printf "Title: ~a\n" (tags-title tags))
(printf "Artist: ~a\n" (tags-artist tags))
(printf "Album: ~a\n" (tags-album tags))
(printf "Length: ~a seconds\n" (tags-length tags))
(when (tags-picture tags)
(define ext (or (tags-picture->ext tags) 'bin))
(tags-picture->file tags (format "cover.~a" ext)))])]
@section{Implementation notes}
This chapter documents the public @racketmodname["taglib.rkt"] layer. The
native TagLib calls are delegated to @racketmodname["taglib-ffi.rkt"], but
callers normally should not use that lower level module directly.
The tag handle is implemented as a small Racket object with a private dispatch
procedure. The native TagLib file is not stored in the handle. This keeps the
public API simple and prevents native resources from leaking into application
code.
The implementation normalizes generic property names by lower-casing TagLib
property keys and converting them to symbols. Values remain lists of strings
because TagLib properties may contain multiple values for one key.