Documentation added
This commit is contained in:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -8,18 +8,31 @@
|
||||
|
||||
(define scribblings
|
||||
'(
|
||||
("scrbl/libao.scrbl" () (library))
|
||||
("scrbl/audio-decoder.scrbl" () (library))
|
||||
("scrbl/flac-decoder.scrbl" () (library))
|
||||
("scrbl/mp3-decoder.scrbl" () (library))
|
||||
("scrbl/audio-sniffer.scrbl" () (library))
|
||||
("scrbl/ffmpeg-ffi.scrbl" () (library))
|
||||
("scrbl/ffmpeg-decoder.scrbl" () (library))
|
||||
("scrbl/ffmpeg-c-backend.scrbl" () (library))
|
||||
("scrbl/ffmpeg-definitions.scrbl" () (library))
|
||||
("scrbl/libao-async-ffi-racket.scrbl" () (library))
|
||||
)
|
||||
)
|
||||
;; Main package overview.
|
||||
;; The negative sort number makes this document appear before the
|
||||
;; other racket-audio manuals in the library documentation listing.
|
||||
("scrbl/racket-audio.scrbl" () (library -100))
|
||||
|
||||
;; High-level user-facing APIs.
|
||||
("scrbl/audio-player.scrbl" () (library -90))
|
||||
("scrbl/taglib.scrbl" () (library -80))
|
||||
("scrbl/play-test.scrbl" () (library -70))
|
||||
|
||||
;; 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
|
||||
'("racket/gui" "racket/base" "racket"
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
"tests.rkt"
|
||||
)
|
||||
|
||||
(define place-mode #f)
|
||||
(define place-mode #t)
|
||||
|
||||
(define run-queue #f)
|
||||
(define (set-test a)
|
||||
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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 |
@@ -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.
|
||||
Reference in New Issue
Block a user