From 17838e4f3309e342b40aaf1d8ab222fad2577bc7 Mon Sep 17 00:00:00 2001 From: Hans Dijkema Date: Sat, 16 May 2026 01:38:40 +0200 Subject: [PATCH] Documentation added --- ao-placed-player.rkt | 137 ------- ao-player.rkt | 229 ----------- .../libao-async-ffi.rkt | 0 .../test-player-2.rkt | 0 ffmpeg-definitions.rkt | 360 +++++++++++++++++- info.rkt | 37 +- play-test.rkt | 2 +- scrbl/audio-placed-player.scrbl | 290 ++++++++++++++ scrbl/audio-player.scrbl | 275 +++++++++++++ scrbl/placed-player-state-model-2.plantuml | 91 +++++ scrbl/placed-player-state-model.svg | 1 + scrbl/placed-player-worker-detail-model.svg | 1 + scrbl/play-test.scrbl | 206 ++++++++++ scrbl/play-workerthread-states.plantuml | 123 ++++++ scrbl/racket-audio.scrbl | 96 +++++ scrbl/rktplayer.svg | 73 ++++ scrbl/taglib.scrbl | 219 +++++++++++ 17 files changed, 1746 insertions(+), 394 deletions(-) delete mode 100644 ao-placed-player.rkt delete mode 100644 ao-player.rkt rename libao-async-ffi.rkt => archive/libao-async-ffi.rkt (100%) rename test-player-2.rkt => archive/test-player-2.rkt (100%) create mode 100644 scrbl/audio-placed-player.scrbl create mode 100644 scrbl/audio-player.scrbl create mode 100644 scrbl/placed-player-state-model-2.plantuml create mode 100644 scrbl/placed-player-state-model.svg create mode 100644 scrbl/placed-player-worker-detail-model.svg create mode 100644 scrbl/play-test.scrbl create mode 100644 scrbl/play-workerthread-states.plantuml create mode 100644 scrbl/racket-audio.scrbl create mode 100644 scrbl/rktplayer.svg create mode 100644 scrbl/taglib.scrbl diff --git a/ao-placed-player.rkt b/ao-placed-player.rkt deleted file mode 100644 index dfb84d3..0000000 --- a/ao-placed-player.rkt +++ /dev/null @@ -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)]))) \ No newline at end of file diff --git a/ao-player.rkt b/ao-player.rkt deleted file mode 100644 index 524af7b..0000000 --- a/ao-player.rkt +++ /dev/null @@ -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))) \ No newline at end of file diff --git a/libao-async-ffi.rkt b/archive/libao-async-ffi.rkt similarity index 100% rename from libao-async-ffi.rkt rename to archive/libao-async-ffi.rkt diff --git a/test-player-2.rkt b/archive/test-player-2.rkt similarity index 100% rename from test-player-2.rkt rename to archive/test-player-2.rkt diff --git a/ffmpeg-definitions.rkt b/ffmpeg-definitions.rkt index 59a1b8a..af5e6e9 100644 --- a/ffmpeg-definitions.rkt +++ b/ffmpeg-definitions.rkt @@ -37,19 +37,33 @@ ;;;; Helpers for constants +;; These helpers mirror FFmpeg macros that normally live in C headers. +;; Racket cannot link macros, so only the needed values are rebuilt +;; here. + +;; Builds an FFmpeg fourcc/tag value from four characters. +;; FFmpeg uses these tags, among other things, to make error codes recognizable. (define (mktag a b c d) (bitwise-ior (char->integer a) (arithmetic-shift (char->integer b) 8) (arithmetic-shift (char->integer c) 16) (arithmetic-shift (char->integer d) 24))) +;; Converts a fourcc/tag to a negative FFmpeg error code. +;; FFmpeg encodes some errors, such as EOF, as negative tag values. (define (fferrtag a b c d) (- (mktag a b c d))) +;; Converts an errno value to an FFmpeg AVERROR code. +;; This keeps comparisons with FFmpeg return values consistent. (define (AVERROR e) (* -1 e)) ;;;; Load libraries and get major library versions. +;; Libraries are resolved in a platform-dependent way. On Windows the +;; FFmpeg DLL names often contain the major version; on Unix-like systems +;; the dynamic linker can usually find the generic soname. + (define libavutil (get-lib (case (system-type 'os) [(windows) '("avutil-60")] [else '("avutil" "libavutil")]) '(#f))) @@ -87,7 +101,11 @@ ;; Version check ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Returns the runtime version of an FFmpeg library as a list. +;; The major/minor/micro values are extracted from FFmpeg's packed integer, +;; so the rest of the module can choose version-dependent layouts. (define (ffmpeg-version lib) + ;; FFmpeg packs versions as major<<16 | minor<<8 | micro. (let ((v (λ (v) (list (quotient v 65536) (remainder (quotient v 256) 256) (remainder v 256))))) (cond ((eq? lib 'avutil) (v (avutil_version))) ((eq? lib 'avcodec) (v (avcodec_version))) @@ -99,16 +117,22 @@ ) ) +;; Formats the runtime version of an FFmpeg library as text. +;; This is mainly used in error messages and logging. (define (ffmpeg-version-string lib) (apply format (cons "~a.~a.~a" (ffmpeg-version lib)))) ;; Support ffmpeg 6, 7 and 8 +;; Checks at load time whether the detected FFmpeg major versions are supported. +;; The struct layouts below are deliberately partial and major-version-dependent; +;; therefore an unknown major version must fail early and loudly. (define-syntax check-support (syntax-rules () ((_ lib version-hash) (let ((from (car (hash-ref version-hash lib))) (until (cadr (hash-ref version-hash lib)))) + ;; Only the major version determines whether the C struct layouts below are safe. (let ((major-version (car (ffmpeg-version lib)))) (cond ((or (< major-version from) (> major-version until)) @@ -133,6 +157,8 @@ ;;;; Constants +;; AVRational is small and stable enough to define completely. +;; FFmpeg uses this struct everywhere for time_base and scaling factors. (define-cstruct _AVRational ([num _int] [den _int] @@ -169,10 +195,51 @@ (define _AVMediaType _int) (define _AVSampleFormat _int) +;;;; C structure layout strategy +;; +;; Racket's ffi/unsafe `define-cstruct` describes a C struct layout to the +;; FFI. It creates a C type descriptor, a pointer type, and generated +;; accessors such as `AVRational-num` or `AVFrame-nb_samples`. For small, +;; stable structs we use `define-cstruct` directly and list all relevant +;; fields in order. Racket then calculates the field offsets from the C +;; types and the platform ABI. +;; +;; The large FFmpeg structs below are different. Types such as +;; AVCodecParameters, AVStream, AVFormatContext, AVFrame, and AVPacket are +;; public enough to read, but their exact field layout changes between +;; FFmpeg major versions and sometimes depends on compatibility fields. +;; Defining every field would make this module brittle and noisy, while +;; defining only the fields we need without their real offsets would be +;; wrong. +;; +;; The local helper `def-cstruct` from private/cstruct-helper.rkt solves +;; this by separating offset calculation from accessor creation: +;; +;; - `make-offsets` receives the complete field sequence up to the last +;; field this module needs. Unnamed entries, for example `_pointer` or +;; `(6 _int)`, are fields that exist only to move the offset forward. +;; - Named entries, for example `(sample_rate _int)`, are the fields for +;; which this module wants generated accessors. +;; - `make-offsets` expands repeated entries and calls `compute-offsets`, +;; so alignment and pointer size are calculated by Racket's FFI for the +;; current platform instead of being hard-coded. +;; - `def-cstruct` then expands back to `define-cstruct`, but only for the +;; named fields, using explicit `#:offset` clauses. The result is a +;; partial struct with correct offsets and with accessors only for the +;; fields this module actually reads. +;; +;; This does not make FFmpeg layout changes disappear: the field sequence +;; below must still match the headers of the loaded major version. The +;; version checks and version-specific branches are therefore part of the +;; safety model. The benefit is that the Racket side stays small while the +;; generated accessors still read from the same byte offsets as C would. +;; ;;;; struct types and partial struct types. ;;;; the least necessary for ffmpeg and wrappers +;; AVChannelLayout is defined partially because playback only needs nb_channels. +;; The remaining fields keep the offsets correct. (define-cstruct _AVChannelLayout ([order _int] ; enum AVChannelOrder [nb_channels _int] @@ -188,13 +255,16 @@ ; sample_rate : int ; libavcodec stuff. +;; AVCodecParameters differs between FFmpeg majors. Only the fields needed +;; for audio playback are named; intervening fields are included as +;; padding/offset fields so the accessor reads from the right place. (def-cstruct _AVCodecParameters (codec_type codec_id format ch_layout sample_rate) (if (= avcodec-version-major 60) ;; ffmpeg 6 definition is different and probably backward compatible with ffmpeg 5 ;; This is with old channel layout in compiled, which is the case on linux. - ;; Probably always, because of back compatibility with ffmpeg 5.0 probably. + ;; Probably always, because of backwards compatibility with ffmpeg 5.0 probably. (make-offsets (codec_type _AVMediaType) (codec_id _AVCodecID) @@ -208,16 +278,16 @@ _int64 ;; bit_rate (6 _int) ;; bits_per_coded_sample, bits_per_raw_sample, - ;; profile, level, width, height + ;; profile, level, width, height _AVRational ;; sample_aspect_ratio (6 _int) ;; field_order, color_range, color_primaries, - ;; color_trc, color_space, chroma_location + ;; color_trc, color_space, chroma_location _int ;; video_delay - ;; Alleen als FF_API_OLD_CHANNEL_LAYOUT actief is. + ;; Only when FF_API_OLD_CHANNEL_LAYOUT is active. _uint64 ;; channel_layout _int ;; channels @@ -228,8 +298,8 @@ (ch_layout _AVChannelLayout) - ;; framerate, coded_side_data, nb_coded_side_data komen hierna, - ;; maar die heb je niet nodig. + ;; framerate, coded_side_data, and nb_coded_side_data follow here, + ;; but they are not needed. ) ;;;;;;; ffmpeg 7 and 8. (major versions 61 and 62). (make-offsets (codec_type _AVMediaType) @@ -264,19 +334,29 @@ ) +;; Reads the codec id from AVCodecParameters. +;; The wrapper hides the generated cstruct accessor behind a more stable name. (define (avcodec-pars-codec_id s) (AVCodecParameters-codec_id s)) +;; Reads the media type from AVCodecParameters. +;; This lets the module distinguish audio streams from video/subtitle streams. (define (avcodec-pars-codec_type s) (AVCodecParameters-codec_type s)) +;; Reads the sample rate from AVCodecParameters. +;; The decoder uses this value for timing, resampling, and sample positions. (define (avcodec-pars-sample_rate s) (AVCodecParameters-sample_rate s)) +;; Reads the number of channels from the new AVChannelLayout. +;; Only this value is needed for interleaved PCM output and buffer sizing. (define (avcodec-pars-channels s) (AVChannelLayout-nb_channels (AVCodecParameters-ch_layout s))) +;; Reads the FFmpeg sample format from AVCodecParameters. +;; This is the input format for swresample. (define (avcodec-pars-format s) (AVCodecParameters-format s)) @@ -285,18 +365,33 @@ ; time_base : AVRational ; duration : int64 +;; AVStream is defined partially. The needed fields are codecpar, time_base, +;; and duration: enough to open the stream and calculate positions. (def-cstruct _AVStream (codec time_base duration) - (make-offsets _pointer (2 _int) (codec _AVCodecParameters-pointer) _pointer (time_base _AVRational) _int64 (duration _int64)) + (make-offsets + _pointer + (2 _int) + (codec _AVCodecParameters-pointer) + _pointer + (time_base _AVRational) + _int64 + (duration _int64)) ) +;; Returns the codec parameters of an AVStream. +;; The rest of the code works only with codecpar, not with old codec fields. (define (avstream-codec s) (AVStream-codec s)) +;; Reads the stream duration in stream time_base ticks. +;; This duration is preferred over the container duration when FFmpeg knows it. (define (avstream-duration s) (AVStream-duration s)) +;; Reads the time_base of an AVStream. +;; This rational is needed to scale timestamps and duration to seconds/samples. (define (avstream-time_base s) (AVStream-time_base s)) @@ -306,23 +401,46 @@ ; duration : int64 ; bit_rate : int64 +;; AVFormatContext is defined partially. The module only uses the stream array, +;; stream count, container duration, and bitrate; metadata is deliberately ignored. (def-cstruct _AVFormatContext (nb_streams streams duration bit_rate) - (make-offsets (5 _pointer) _int (nb_streams _int) (streams _pointer) _uint _pointer _uint _pointer _string*/utf-8 _int64 (duration _int64) (bit_rate _int64)) + (make-offsets + (5 _pointer) + _int + (nb_streams _int) + (streams _pointer) + _uint + _pointer + _uint + _pointer + _string*/utf-8 + _int64 + (duration _int64) + (bit_rate _int64)) ) +;; Reads how many streams the container contains. +;; This determines the upper bound when searching for audio streams. (define (avformat_nb_streams s) (AVFormatContext-nb_streams s)) +;; Fetches stream i from AVFormatContext.streams. +;; AVFormatContext.streams is an AVStream**; therefore the array pointer is +;; read first, and then the i-th stream pointer is loaded. (define (avformat_stream s i) (let ((streams-ptr (AVFormatContext-streams s))) (ptr-ref streams-ptr _AVStream-pointer i) )) +;; Reads the container duration in AV_TIME_BASE units. +;; This is the fallback when the selected audio stream has no duration itself. (define (avformat_duration s) (AVFormatContext-duration s)) +;; Reads the container bitrate. +;; The public API returns -1 when FFmpeg reports no positive bitrate. (define (avformat_bit_rate s) (AVFormatContext-bit_rate s)) @@ -331,6 +449,9 @@ ; nb_samples ; best_effort_timestamp +;; AVFrame is one of the most sensitive layouts: FFmpeg 6 differs from 7/8. +;; Only data, nb_samples, sample_rate, and best_effort_timestamp are named; +;; the rest is included as offset information. (def-cstruct _AVFrame (data nb_samples sample_rate best_effort_timestamp) @@ -390,25 +511,35 @@ ) ) +;; Passes the address that swr_convert expects as const uint8_t **. +;; AVFrame starts with data[8], so the frame pointer itself points at data[0]. (define (avframe-data frame) - ;; swr_convert wil const uint8_t **. - ;; AVFrame begint met uint8_t *data[8], dus de frame pointer zelf - ;; is het adres van data[0]. + ;; swr_convert wants const uint8_t **. + ;; AVFrame starts with uint8_t *data[8], so the frame pointer itself + ;; is the address of data[0]. frame) +;; Returns the first data plane of an AVFrame. +;; This is only useful if direct access to plane 0 is needed later. (define (avframe-data0 frame) - ;; Alleen als je ooit de eerste plane pointer zelf nodig hebt. + ;; Only useful if the first plane pointer itself is ever needed. (AVFrame-data frame)) +;; Reads how many sample frames the current AVFrame contains. +;; This value determines how much input is offered to swresample. (define (avframe-nb-samples frame) (AVFrame-nb_samples frame)) +;; Reads the best-effort timestamp from an AVFrame. +;; That timestamp is the best basis for sample position and seek correction. (define (avframe-best-effort-timestamp frame) (AVFrame-best_effort_timestamp frame)) ; AVPacket ; stream_index +;; AVPacket is defined partially because only stream_index is needed. +;; It lets the demuxer skip packets from other streams. (def-cstruct _AVPacket (stream_index) @@ -420,16 +551,25 @@ _int ; size (stream_index _int))) +;; Reads which stream a packet belongs to. +;; Packets from other streams are ignored before they reach the audio decoder. (define (avpacket-stream-index pkt) (AVPacket-stream_index pkt)) ;;;;; Now import the needed functions +;; The FFI imports below are the minimal FFmpeg functions for: +;; opening/demuxing, codec initialization, frame decode, resampling, seek, and cleanup. +;; Functions that use C pointer-to-pointer cleanup get a raw binding +;; plus a small Racket wrapper that normalizes the stored pointer to #f. + (def-avcodec avcodec_free_context/raw (_fun (_ptr io _AVCodecContext) -> (p : _AVCodecContext) -> p ) #:c-id avcodec_free_context) +;; Frees an AVCodecContext and normalizes the result to #f. +;; FFmpeg sets the C pointer to NULL through pointer-to-pointer; the Racket side then stores #f. (define (avcodec_free_context ctx) (if ctx (begin (avcodec_free_context/raw ctx) #f) @@ -439,6 +579,8 @@ -> (p : _SwrContext) -> p) #:c-id swr_free) +;; Frees a SwrContext and normalizes the result to #f. +;; This prevents decoder-storage from keeping an old native pointer after cleanup. (define (swr_free ctx) (if ctx (begin (swr_free/raw ctx) #f) @@ -448,6 +590,8 @@ -> (p : _AVFormatContext-pointer/null) -> p) #:c-id avformat_close_input) +;; Closes an AVFormatContext and normalizes the result to #f. +;; This follows FFmpeg ownership: avformat_close_input also closes the input resource. (define (avformat_close_input inp) (if inp (begin (avformat_close_input/raw inp) #f) @@ -490,6 +634,8 @@ -> (p : _AVFrame-pointer/null) -> p) #:c-id av_frame_free) +;; Frees an AVFrame and normalizes the result to #f. +;; The wrapper prevents the Racket struct from keeping a dangling frame pointer. (define (av_frame_free frm) (if frm (begin (av_frame_free/raw frm) #f) @@ -524,6 +670,8 @@ -> (p : _AVCodecParameters-pointer/null) -> p) #:c-id avcodec_parameters_free) +;; Frees temporarily used AVCodecParameters and returns #f. +;; This is used for parameter copies that are only needed during initialization. (define (avcodec_parameters_free par) (if par (begin (avcodec_parameters_free/raw par) #f) @@ -575,6 +723,8 @@ -> (p : _AVPacket-pointer/null) -> p) #:c-id av_packet_free) +;; Frees an AVPacket and normalizes the result to #f. +;; Packets are short-lived and are explicitly cleaned up to save native memory. (define (av_packet_free pkg) (if pkg (begin (av_packet_free/raw pkg) #f) @@ -602,6 +752,8 @@ ;; Constants ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; The module always produces 32-bit signed PCM. This keeps the rest of +;; racket-audio from having to deal with every possible FFmpeg sample format. (define FMPG_OUTPUT_BITS 32) (define FMPG_OUTPUT_BYTES 4) (define FMPG_OUTPUT_FMT AV_SAMPLE_FMT_S32) @@ -610,6 +762,8 @@ ;; Internal structures for ffmpeg decoding ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Creates short aliases for struct accessors and mutators. +;; This keeps the decoder code readable without long Racket struct names. (define-syntax def-struct-helpers (syntax-rules () ((_ (struct-get get struct-set set)) @@ -623,6 +777,8 @@ ) ) +;; Defines several short struct helper names at once. +;; This is purely syntactic bookkeeping for the storage structs below. (define-syntax struct-helpers (syntax-rules () ((_ a ...) @@ -633,6 +789,8 @@ ;;;;;;;;;; audio-info-storage +;; audio-info-storage contains only playback-relevant information. +;; Tags/metadata are deliberately omitted; the player needs rate, channels, stream, and duration. (define-struct audio-info-storage (audio-stream-count selected-stream-index @@ -664,6 +822,8 @@ ) +;; Resets audio-info to a closed/unknown state. +;; This prevents old stream data from remaining visible after close or failed open. (define (ais-clear! s) (ais-stream-count! s 0) (ais-stream-index! s -1) @@ -673,12 +833,16 @@ (ais-duration-samples! s -1) ) +;; Creates an empty audio-info storage structure. +;; The values mean: no stream selected and duration still unknown. (define (new-audio-info-storage) (let ((a (make-audio-info-storage 0 -1 0 0 -1 -1))) a)) ;;;;;;;;;;; decoder storage +;; decoder-storage owns the native decode resources and keeps the running +;; sample bookkeeping. PCM is the last produced Racket bytes buffer. (define-struct decoder-storage (codec ; _AVCodec - Not owned by decoder-storage, global pointer codec-ctx ; _AVCodecContext @@ -702,12 +866,18 @@ #:transparent ) +;; Tests whether the decoder currently has no PCM output buffer. +;; This is used to set the start position and timecode only on first output. (define (pcm-empty? dec) (zero? (bytes-length (ds-pcm dec)))) +;; Tests whether PCM bytes are available for the caller. +;; The public buffer function uses this to return #f for empty output. (define (pcm-present? dec) (positive? (bytes-length (ds-pcm dec)))) +;; Combines decoder status with the actual PCM buffer. +;; A positive decode step only counts as success when bytes are present too. (define (produced-pcm? produced dec) (and (> produced 0) (pcm-present? dec))) @@ -730,6 +900,8 @@ set-decoder-storage-discard-until-sample! ds-discard-until!) ) +;; Cleans up all native decoder resources in decoder-storage. +;; Codec context, frame, and resampler are owned by this storage and must go together. (define (free-ffmpeg s) (when s (ds-codec-ctx! s (avcodec_free_context (ds-codec-ctx s))) @@ -740,12 +912,16 @@ ) ) +;; Clears only the last produced PCM output. +;; Decoder and seek state remain intact so the next decode step can continue. (define (ds-clear-output! s) (ds-pcm! s (make-bytes 0)) (ds-last-samples! s 0) (ds-start-sample! s (ds-next-sample-pos s)) ) +;; Resets decoder-storage to initial values without opening native resources itself. +;; This is used after cleanup and during reinitialization to wipe old state. (define (reset-decoder-storage! s) (ds-codec! s #f) (ds-pcm! s (make-bytes 0)) @@ -757,6 +933,8 @@ (ds-next-sample-pos! s 0) (ds-discard-until! s -1)) +;; Creates decoder-storage and registers a finalizer for native cleanup. +;; The finalizer is a safety net; normal code closes resources explicitly earlier. (define (new-decoder-storage) (let ((ds (make-decoder-storage #f #f #f #f (make-bytes 0) @@ -769,6 +947,8 @@ ;;;;;;;;;;;;;; fmpg-instance +;; An fmpg-instance groups the container context, audio-info, and decoder state. +;; The public API always works on such an instance, comparable to a C handle. (define-struct fmpg-instance (opened ; boolean format-ctx ; AVFormatContext @@ -779,6 +959,8 @@ #:transparent ) +;; Creates a new decode instance with its own format context, info, and decoder state. +;; Here too, a finalizer performs cleanup if the user does not close explicitly. (define (new-fmpg-instance) (let ((i (make-fmpg-instance #f #f (new-audio-info-storage) (new-decoder-storage)))) (register-finalizer i @@ -795,6 +977,8 @@ ;; Helper functions for ffmpeg decoding ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Converts an AVRational to an inexact number. +;; FFmpeg timestamps use rationals; Racket can then calculate more easily in seconds. (define-syntax av_q2d (syntax-rules () ((_ a) @@ -803,7 +987,10 @@ ) ) +;; Counts how many streams in the container are audio streams. +;; This is informative for the API; actual selection is done with av_find_best_stream. (define (count-audio-streams avformat-ctx) + ;; Without a format context there is nothing to count; 0 is the safe API value. (if (eq? avformat-ctx #f) 0 (let ((count 0) @@ -811,6 +998,7 @@ (i 0) ) (while (< i nb_streams) + ;; For each stream, inspect codec parameters; only audio counts. (let* ((stream (avformat_stream avformat-ctx i)) (par (avstream-codec stream)) ) @@ -828,17 +1016,23 @@ ) ) +;; Converts seconds to milliseconds, or -1 for unknown/negative duration. +;; Rounding avoids systematic underestimation from floating point conversion. (define (milliseconds_from_seconds seconds) (if (< seconds 0.0) -1 (inexact->exact (round (+ (* seconds 1000.0) 0.5))))) +;; Converts seconds to sample frames, or -1 for unknown duration/rate. +;; This makes duration and seek positions usable for the audio player. (define (samples_from_seconds seconds sample-rate) (if (or (< seconds 0.0) (<= sample-rate 0)) -1 (inexact->exact (round (+ (* seconds sample-rate) 0.5))))) +;; Computes the duration of a stream in seconds. +;; AV_NOPTS_VALUE means FFmpeg does not know a reliable stream duration. (define (stream_duration_seconds stream) (if (eq? stream #f) -1.0 @@ -849,6 +1043,8 @@ ) ) +;; Computes the container duration in seconds. +;; This is fallback information when the selected stream has no duration. (define (format_duration_seconds ctx) (if (eq? ctx #f) -1 @@ -859,6 +1055,8 @@ ) ) +;; Converts an FFmpeg timestamp to a sample position. +;; If timestamp, stream, or sample rate is missing, -1 is returned as unknown. (define (timestamp_to_samples timestamp stream sample_rate) (if (or (eq? stream #f) (= timestamp AV_NOPTS_VALUE) (<= sample_rate 0)) -1 @@ -866,19 +1064,27 @@ (* timestamp (av_q2d (avstream-time_base stream)))))) (samples_from_seconds seconds sample_rate)))) +;; Accepts both path? and string? values as filenames. +;; Other values become #f so fmpg-open-file! can reject them easily. (define (filename->string filename) (cond [(path? filename) (path->string filename)] [(string? filename) filename] [else #f])) +;; Fills audio-info for the best audio stream in the opened file. +;; The function checks stream, codec parameters, type, rate, and channels before +;; the instance is considered usable. (define (fill-audio-info! self) (let* ((ctx (fmpg-instance-format-ctx self)) (info (fmpg-instance-audio-info self))) + ;; Always start with clean info so a failed inspection leaves no old values behind. (ais-clear! info) (ais-stream-count! info (count-audio-streams ctx)) + ;; Each binding checks a condition required for safe playback: + ;; best stream found, stream/parameters exist, type is audio, rate/channels are positive. (early-return ((best (av_find_best_stream ctx AVMEDIA_TYPE_AUDIO -1 -1 #f 0) ? (< best 0) => #f) (stream (avformat_stream ctx best) ? (not (a-!nullptr? stream)) => #f) @@ -886,9 +1092,11 @@ (codec-type (avcodec-pars-codec_type par) ? (not (= codec-type AVMEDIA_TYPE_AUDIO)) => #f) (sample-rate (avcodec-pars-sample_rate par) ? (<= sample-rate 0) => #f) (channels (avcodec-pars-channels par) ? (<= channels 0) => #f) + ;; Stream duration is more precise; container duration is the fallback for files without one. (stream-seconds (stream_duration_seconds stream)) (seconds (if (< stream-seconds 0.0) (format_duration_seconds ctx) stream-seconds))) + ;; Only after all checks does the selected stream become visible in audio-info. (ais-stream-index! info best) (ais-rate! info sample-rate) (ais-channels! info channels) @@ -898,7 +1106,10 @@ ) ) +;; Checks whether an instance is ready for decode or query operations. +;; There must be an open file, a selected stream, and an active codec/resampler. (define (instance-ready? instance) + ;; All parts must be present before decode or query is safe. (let ((ready (and instance (fmpg-instance-opened instance) (fmpg-instance-format-ctx instance) @@ -916,8 +1127,13 @@ +;; Initializes the FFmpeg codec context for the selected audio stream. +;; The decoder is found, a context is allocated, parameters are copied, +;; and the codec is opened before frames can be received. (define (init-codec-context! self) (early-return + ;; The early-return chain prevents later FFmpeg calls from seeing NULL pointers. + ;; Base values come from the selected stream and decoder state. ((dec (fmpg-instance-decoder self)) (info (fmpg-instance-audio-info self)) (ctx (fmpg-instance-format-ctx self)) @@ -925,15 +1141,18 @@ (stream (avformat_stream ctx stream-index) ? (not (a-!nullptr? stream)) => #f) (par (avstream-codec stream) ? (not (a-!nullptr? par)) => #f) + ;; The decoder belongs to the codec_id from the stream parameters; the pointer is not owned. (codec (let ((c (avcodec_find_decoder (avcodec-pars-codec_id par)))) (ds-codec! dec c) c) ? (not (a-!nullptr? codec)) => #f) + ;; The codec context is owned by decoder-storage and is freed explicitly later. (codec-ctx (let ((c (avcodec_alloc_context3 codec))) (ds-codec-ctx! dec c) c) ? (not (a-!nullptr? codec-ctx)) => #f) + ;; FFmpeg wants codec parameters in the context before the codec can be opened. (ret-par (avcodec_parameters_to_context codec-ctx par) ? (< ret-par 0) => #f) (ret-open (avcodec_open2 codec-ctx codec #f) ? (< ret-open 0) => #f) (frame (let ((f (av_frame_alloc))) @@ -944,8 +1163,12 @@ ) +;; Initializes swresample for interleaved 32-bit signed PCM output. +;; Current codec parameters are copied temporarily from the codec context, +;; so channel layout, input format, and sample rate remain consistent. (define (init-resampler! self) (early-return + ;; swresample is configured only after the codec context exists. ((dec (fmpg-instance-decoder self)) (codec-ctx (ds-codec-ctx dec) ? (not (a-!nullptr? codec-ctx)) => #f) (par (avcodec_parameters_alloc) ? (not (a-!nullptr? par)) => #f) @@ -953,10 +1176,12 @@ (result (early-return ((ret-par (avcodec_parameters_from_context par codec-ctx) ? (< ret-par 0) => #f) + ;; Use the same channel layout for input and output: only the sample format is normalized. (layout (AVCodecParameters-ch_layout par)) (channels (AVChannelLayout-nb_channels layout) ? (<= channels 0) => #f) (rate (avcodec-pars-sample_rate par) ? (<= rate 0) => #f) (fmt (avcodec-pars-format par)) + ;; swr_alloc_set_opts2 allocates or reuses the SwrContext via pointer-to-pointer. (ret-swr (let-values (((ret swr-ctx) (swr_alloc_set_opts2 (ds-swr-ctx dec) @@ -977,14 +1202,20 @@ ) +;; Reinitializes the codec and resampler for the current instance. +;; First the old native state is freed; then codec and resampler are rebuilt. (define (init-decoder! self) (let ((dec (fmpg-instance-decoder self))) + ;; Old native state must be gone before the instance reuses the same storage. (free-ffmpeg dec) (reset-decoder-storage! dec) (and (init-codec-context! self) (init-resampler! self)))) +;; Public constructor for an FFmpeg decode instance. +;; Initialization errors are caught so the C-like API can return #f. (define (fmpg-init) + ;; This constructor is intended as a robust boundary: exceptions become #f. (with-handlers ([exn:fail? (lambda (e) #f)]) (new-fmpg-instance))) @@ -992,7 +1223,11 @@ ;; API ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Opens an audio file and initializes all decode state. +;; The function fails safely with 0: at every failed step, the half-open instance is closed. (define (fmpg-open-file! instance filename) + ;; First check the API preconditions: valid instance, not already open, + ;; no old format context, and a filename that FFmpeg can open. (let/assert ((instance instance a-!nullptr? 0) (opened (fmpg-instance-opened instance) a-false? 0) @@ -1000,22 +1235,30 @@ (filename (filename->string filename) string? 0)) (let ((result (let/assert + ;; Opening happens first; the received AVFormatContext is stored directly in the instance + ;; so cleanup after later errors can use the same path. ((ret-open (let-values (((r ctx) (avformat_open_input #f filename #f #f))) (set-fmpg-instance-format-ctx! instance ctx) r) (a->=? 0) 0) (ctx (fmpg-instance-format-ctx instance) a-!nullptr? 0) + ;; Stream info is needed before codecpar, duration, and best stream are reliable. (ret-info (avformat_find_stream_info ctx #f) (a->=? 0) 0) (info-ok (fill-audio-info! instance) a-true? 0) (dec-ok (init-decoder! instance) a-true? 0)) (begin (set-fmpg-instance-opened! instance #t) 1)))) + ;; Every partial failure rolls back to a closed instance. (when (zero? result) (fmpg-close! instance)) result))) +;; Closes an instance and cleans up all native resources. +;; Audio-info is cleared so later queries do not return stale metadata. (define (fmpg-close! instance) (when instance + ;; Decoder resources first, then the container; this way decoder pointers no longer + ;; refer to streams from an already-closed format context. (free-ffmpeg (fmpg-instance-decoder instance)) (set-fmpg-instance-format-ctx! instance (avformat_close_input @@ -1025,36 +1268,52 @@ (when info (ais-clear! info))))) +;; Returns 1 when the instance is ready to decode, otherwise 0. +;; The API uses integers because the outside interface is C-like. (define (fmpg-is-open instance) (if (instance-ready? instance) 1 0)) +;; Returns the number of audio streams in the opened file. +;; For a closed or missing instance, 0 is the safe value. (define (fmpg-audio-stream-count instance) (if (and instance (fmpg-instance-opened instance)) (ais-stream-count (fmpg-instance-audio-info instance)) 0)) +;; Returns the sample rate of the selected audio stream. +;; For an instance that is not ready, 0 is returned. (define (fmpg-audio-sample-rate instance) (if (instance-ready? instance) (ais-rate (fmpg-instance-audio-info instance)) 0)) +;; Returns the number of channels in the selected audio stream. +;; For an instance that is not ready, 0 is returned. (define (fmpg-audio-channels instance) (if (instance-ready? instance) (ais-channels (fmpg-instance-audio-info instance)) 0)) +;; Returns the bit depth of the PCM output. +;; The decoder always normalizes to 32-bit signed samples. (define (fmpg-audio-bits-per-sample instance) FMPG_OUTPUT_BITS) +;; Returns the number of bytes per PCM sample. +;; This follows FMPG_OUTPUT_BITS and therefore remains constant at 4. (define (fmpg-audio-bytes-per-sample instance) FMPG_OUTPUT_BYTES) +;; Returns the selected audio duration in milliseconds. +;; -1 means FFmpeg could not provide a usable duration. (define (fmpg-duration-ms instance) (if (instance-ready? instance) (ais-duration-ms (fmpg-instance-audio-info instance)) -1)) +;; Returns the selected audio duration in sample frames. +;; -1 means unknown; the value is computed from stream or container duration. (define (fmpg-duration-samples instance) (if (instance-ready? instance) (ais-duration-samples (fmpg-instance-audio-info instance)) @@ -1064,15 +1323,19 @@ ;; Decoding ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Appends native PCM bytes to the Racket output buffer. +;; The function checks the pointer and maximum buffer size before copying bytes. (define (append-bytes! dec src nbytes) (cond [(zero? nbytes) #t] [else + ;; src must be a native pointer and the new buffer must not grow beyond INT_MAX. (let/assert ((src src a-!nullptr? #f) (pcm (ds-pcm dec)) (old-size (bytes-length pcm)) (new-size (+ old-size nbytes) (a-<=? INT_MAX) #f)) + ;; Racket bytes are the ownership form that the caller can safely keep/read. (let ((new-pcm (make-bytes new-size))) (bytes-copy! new-pcm 0 pcm 0 old-size) (memcpy new-pcm old-size src 0 nbytes) @@ -1080,20 +1343,25 @@ #t))])) +;; Determines which part of a converted frame must be kept. +;; After seek, FFmpeg may start before the target; this cuts pre-roll samples away. (define (select-frame-range! dec frame-start out-samples channels) (let ((keep-start frame-start) (keep-samples out-samples) (byte-offset 0) (dropped-all? #f)) + ;; discard-until is active only immediately after seek. Negative means: discard nothing. (when (>= (ds-discard-until dec) 0) (let* ((target (ds-discard-until dec)) (frame-end (+ frame-start out-samples))) (cond + ;; The whole frame lies before the target position; drop it all and advance position. [(<= frame-end target) (ds-next-sample-pos! dec frame-end) (set! keep-samples 0) (set! dropped-all? #t)] [else + ;; The frame overlaps the target position; skip leading bytes and keep the rest. (when (< frame-start target) (let ((drop (- target frame-start))) (when (and (> drop 0) (< drop out-samples)) @@ -1104,12 +1372,17 @@ (values keep-start keep-samples byte-offset dropped-all?))) +;; Allocates temporary native output buffers for swr_convert and always frees them. +;; swr_convert expects an array of plane pointers; for interleaved output this is an array +;; with exactly one pointer to the temporary PCM buffer. (define (call-with-swr-output-buffer max-bytes proc) (early-return + ;; Both allocations are native because swr_convert reads/writes C pointers. ((tmp (malloc max-bytes 'raw) ? (eq? tmp #f) => #f) (out-planes (malloc _pointer 1 'raw) ? (eq? out-planes #f) => #f ~ (free tmp)) + ;; Interleaved output has one plane; out-planes[0] points to tmp. (do (ptr-set! out-planes _pointer 0 tmp)) (result (proc tmp out-planes))) (free out-planes) @@ -1117,6 +1390,8 @@ result) ) +;; Converts a received AVFrame to 32-bit PCM and appends it to the output buffer. +;; Buffer length, timestamp, seek pre-roll, and sample bookkeeping meet here. (define (append-converted-frame! self frame) (let/assert ((dec (fmpg-instance-decoder self)) @@ -1125,31 +1400,37 @@ (sample-rate (ais-rate info)) (nb-samples (avframe-nb-samples frame))) (cond + ;; No channels or samples means the frame yields no usable audio, but this is not an error. [(or (<= channels 0) (<= nb-samples 0)) #t] [else (let/assert + ;; First ask swresample how much output can be produced and allocate for that. ((max-out-samples (swr_get_out_samples (ds-swr-ctx dec) nb-samples) (a->? 0) #f) (max-bytes (av_samples_get_buffer_size #f channels max-out-samples FMPG_OUTPUT_FMT 1) (a->? 0) #f)) (call-with-swr-output-buffer max-bytes (lambda (tmp out-planes) (let/assert + ;; Convert the frame to the fixed FMPG_OUTPUT_FMT. ((out-samples (swr_convert (ds-swr-ctx dec) out-planes max-out-samples (avframe-data frame) nb-samples) (a->=? 0) #f) (used-bytes (av_samples_get_buffer_size #f channels out-samples FMPG_OUTPUT_FMT 1) (a->=? 0) #f) (stream-index (ais-stream-index info)) (stream (avformat_stream (fmpg-instance-format-ctx self) stream-index) a-!nullptr? #f) + ;; Use the frame timestamp if FFmpeg has one; otherwise continue the current position. (frame-start0 (timestamp_to_samples (avframe-best-effort-timestamp frame) stream sample-rate)) (frame-start (if (< frame-start0 0) (ds-next-sample-pos dec) frame-start0))) (let-values (((keep-start keep-samples byte-offset dropped-all?) (select-frame-range! dec frame-start out-samples channels))) + ;; Apply seek trimming before bytes are appended to the PCM buffer. (cond [dropped-all? #t] [(<= keep-samples 0) (ds-next-sample-pos! dec (+ frame-start out-samples)) #t] [else + ;; The first output in this decode call determines buffer-start and timecode. (when (pcm-empty? dec) (ds-start-sample! dec keep-start) (ds-timecode! dec (/ (exact->inexact keep-start) (exact->inexact sample-rate)))) @@ -1161,19 +1442,25 @@ #t))]))))))]))) +;; Receives all frames currently available from the FFmpeg decoder. +;; The function stops at EAGAIN, marks drain at EOF, and converts each received frame. (define (receive-available-frames! self) (let ((dec (fmpg-instance-decoder self)) (produced 0)) (let loop () + ;; avcodec_receive_frame may return multiple frames after one packet, so loop until EAGAIN/EOF. (let ((ret (avcodec_receive_frame (ds-codec-ctx dec) (ds-frame dec)))) (cond + ;; EAGAIN means: no frame right now; send more packets first. [(= ret AVERROR_EAGAIN) produced] + ;; EOF means the decoder itself is empty; the resampler may still have delay. [(= ret AVERROR_EOF) (ds-drained! dec #t) produced] [(< ret 0) (err-sound "Got retvalue ~a from avcodec_receive_frame" ret) -1] [else (let ((ok? (append-converted-frame! self (ds-frame dec)))) + ;; The AVFrame is reused; unref releases FFmpeg's internal buffers. (av_frame_unref (ds-frame dec)) (if ok? (begin @@ -1183,9 +1470,12 @@ +;; Reads packets until a packet for the selected audio stream is found. +;; Packets from other streams are immediately unref'ed so they keep no native buffers. (define (read-selected-audio-packet! self pkt) (let ((wanted-stream (ais-stream-index (fmpg-instance-audio-info self)))) (let loop () + ;; av_read_frame returns all streams interleaved; filter here on the selected audio stream. (let ((ret (av_read_frame (fmpg-instance-format-ctx self) pkt))) (cond [(= ret AVERROR_EOF) @@ -1199,10 +1489,13 @@ 'packet] [else + ;; Non-audio or non-selected stream: release packet contents immediately and continue. (av_packet_unref pkt) (loop)]))))) +;; Pulls delayed samples out of swresample after the decoder has been drained. +;; Resamplers may still hold output internally; without this step the end of audio is lost. (define (drain-resampler! self) (let* ((dec (fmpg-instance-decoder self)) (info (fmpg-instance-audio-info self)) @@ -1213,6 +1506,7 @@ (let loop ((produced 0)) (early-return + ;; swr_get_delay reports how many samples may still be in the resampler buffer. ((delay (swr_get_delay swr-ctx sample-rate) ? (<= delay 0) => produced) (max-bytes (av_samples_get_buffer_size #f channels delay FMPG_OUTPUT_FMT 1) @@ -1225,6 +1519,7 @@ (do (ptr-set! out-planes _pointer 0 tmp)) + ;; Null input with 0 samples asks swresample to flush delayed output. (out-samples (swr_convert swr-ctx out-planes delay #f 0) ? (<= out-samples 0) => produced ~ (begin @@ -1238,6 +1533,7 @@ (free out-planes) (free tmp))) + ;; If this is the first output, the buffer belongs to the current next-sample-pos. (do (when (pcm-empty? dec) (let ((start-sample (ds-next-sample-pos dec))) @@ -1262,6 +1558,8 @@ ) ) +;; Decodes until PCM is available, EOF is reached, or an error occurs. +;; Return values: 1 = PCM available, 0 = done/no more PCM, negative = error. (define (fmpg-decode-next! instance) (define (r m r . e) @@ -1269,8 +1567,9 @@ (err-sound "fmpg-decode-next! : ~a - ~a - ~a" m r e)) r) - ;; #f = continue, 1 = pcm available, negative = error. + ;; #f = continue reading/sending packets, 1 = PCM available, negative = error. (define (receive-result! self dec) + ;; Try receiving first; FFmpeg may still have frames ready from previous packets. (let ((produced (receive-available-frames! self))) (cond [(< produced 0) -1] @@ -1278,6 +1577,7 @@ [else #f]))) (define (send-packet-result! dec pkt) + ;; After send_packet the packet may always be unref'ed; the decoder has taken what it needs. (let ((ret (avcodec_send_packet (ds-codec-ctx dec) pkt))) (av_packet_unref pkt) ret)) @@ -1287,8 +1587,10 @@ (dec (fmpg-instance-decoder instance)) + ;; Each call produces at most one new public PCM buffer; clear old output first. (do (ds-clear-output! dec)) + ;; Before reading a new packet, first receive pending decoder frames. (received (receive-result! instance dec) ? received => (r "receive-result!" received)) @@ -1298,6 +1600,7 @@ (packet-result (let loop () (cond + ;; If the demuxer already reported EOF, no more packets need to be read. [(ds-eof-seen dec) #f] [else @@ -1315,6 +1618,7 @@ [(eq? packet-status 'packet) (let ((ret (send-packet-result! dec pkt))) (cond + ;; The decoder asks for receive_frame before more packets may be sent. [(= ret AVERROR_EAGAIN) (let ((received (receive-result! instance dec))) (if received received (loop)))] @@ -1335,7 +1639,7 @@ (do (av_packet_free pkt)) - ;; If all packets have been read, flush the decoder. + ;; When all packets have been read, send NULL to drain the decoder. (drain-result (and (not (ds-drained dec)) (let ((ret (avcodec_send_packet (ds-codec-ctx dec) #f))) @@ -1354,7 +1658,7 @@ (receive-result! instance dec)]))) ? drain-result => (r "drain-result" drain-result)) - ;; After decoder drain, flush any delayed samples from swresample. + ;; After decoder drain, samples may still be in swresample; flush those too. (produced (drain-resampler! instance) ? (< produced 0) => (r "drain-resampler!" produced))) @@ -1366,8 +1670,12 @@ +;; Seeks to a position in milliseconds and resets decoder/resampler state. +;; FFmpeg seeks to a suitable earlier packet position; discard-until then corrects +;; the extra samples to exactly the requested sample position. (define (fmpg-seek-ms! instance target-pos-ms) (early-return + ;; Seek is allowed only on a decode-ready instance and to a non-negative position. ((? (or (not (instance-ready? instance)) (< target-pos-ms 0)) => 0) (info (fmpg-instance-audio-info instance)) @@ -1378,14 +1686,17 @@ (stream (avformat_stream ctx stream-index) ? (not (a-!nullptr? stream)) => 0) + ;; Convert milliseconds first to microseconds and then to stream time_base. (pos-us (av_rescale target-pos-ms AV_TIME_BASE 1000)) (stream-ts (av_rescale_q pos-us AV_TIME_BASE_Q (avstream-time_base stream))) + ;; BACKWARD seeks to an earlier key/packet point; exact correction happens via discard-until. (ret-seek (av_seek_frame ctx stream-index stream-ts AVSEEK_FLAG_BACKWARD) ? (< ret-seek 0) => 0) (target-samples (samples_from_seconds (/ target-pos-ms 1000.0) (ais-rate info))) + ;; Old decoder and resampler buffers belong to the position before the seek and must go. (do (avcodec_flush_buffers (ds-codec-ctx dec)) (swr_close (ds-swr-ctx dec))) @@ -1394,6 +1705,7 @@ (pos (if (>= target-samples 0) target-samples 0))) + ;; Reset sample bookkeeping to the target position; later frames may still contain pre-roll. (ds-pcm! dec (make-bytes 0)) (ds-last-samples! dec 0) (ds-start-sample! dec pos) @@ -1405,13 +1717,19 @@ 1) ) +;; Internal helper that safely extracts decoder-storage from an instance. +;; If the instance is #f, #f is returned as well. (define (fmpg-decoder instance) (and instance (fmpg-instance-decoder instance))) +;; Returns the last produced PCM buffer. +;; With no decoder or empty output, #f is returned. (define (fmpg-buffer instance) (let ((dec (fmpg-decoder instance))) (if (and dec (not (pcm-empty? dec))) (ds-pcm dec) #f))) +;; Returns the size of the current PCM buffer in bytes. +;; Values above INT_MAX are reported as 0 because the outside interface uses int sizes. (define (fmpg-buffer-size instance) (let ((dec (fmpg-decoder instance))) (if dec @@ -1419,26 +1737,38 @@ (if (> n INT_MAX) 0 n)) 0))) +;; Returns how many sample frames are in the current PCM buffer. +;; This is independent of the number of channels. (define (fmpg-buffer-samples instance) (let ((dec (fmpg-decoder instance))) (if dec (ds-last-samples dec) 0))) +;; Returns the sample position at the start of the current PCM buffer. +;; The player can use this to connect playback position to buffer contents. (define (fmpg-buffer-start-sample instance) (let ((dec (fmpg-decoder instance))) (if dec (ds-start-sample dec) 0))) +;; Returns the sample position immediately after the current PCM buffer. +;; This is start position plus the number of sample frames in the buffer. (define (fmpg-buffer-end-sample instance) (let ((dec (fmpg-decoder instance))) (if dec (+ (ds-start-sample dec) (ds-last-samples dec)) 0))) +;; Returns the next sample position the decoder expects to produce. +;; This value continues across decode calls and is reset by seek. (define (fmpg-sample-position instance) (let ((dec (fmpg-decoder instance))) (if dec (ds-next-sample-pos dec) 0))) +;; Returns the timecode in seconds of the current output buffer. +;; With no decoder, 0.0 is the safe fallback. (define (fmpg-timecode instance) (let ((dec (fmpg-decoder instance))) (if dec (ds-timecode dec) 0.0))) +;; Returns the container bitrate, or -1 when it is unknown. +;; Only positive FFmpeg bitrates are passed through as reliable. (define (fmpg-file-bitrate instance) (let ((ctx (and instance (fmpg-instance-format-ctx instance)))) (if ctx diff --git a/info.rkt b/info.rkt index d13c4dd..06d12d0 100644 --- a/info.rkt +++ b/info.rkt @@ -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" diff --git a/play-test.rkt b/play-test.rkt index 6465549..e00d33c 100644 --- a/play-test.rkt +++ b/play-test.rkt @@ -9,7 +9,7 @@ "tests.rkt" ) -(define place-mode #f) +(define place-mode #t) (define run-queue #f) (define (set-test a) diff --git a/scrbl/audio-placed-player.scrbl b/scrbl/audio-placed-player.scrbl new file mode 100644 index 0000000..596aa3c --- /dev/null +++ b/scrbl/audio-placed-player.scrbl @@ -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. diff --git a/scrbl/audio-player.scrbl b/scrbl/audio-player.scrbl new file mode 100644 index 0000000..6667168 --- /dev/null +++ b/scrbl/audio-player.scrbl @@ -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. diff --git a/scrbl/placed-player-state-model-2.plantuml b/scrbl/placed-player-state-model-2.plantuml new file mode 100644 index 0000000..839004b --- /dev/null +++ b/scrbl/placed-player-state-model-2.plantuml @@ -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<> #DCEEFF + BorderColor<> #3A7FC4 + FontColor<> #123A63 + + BackgroundColor<> #E3F6E3 + BorderColor<> #3C9D40 + FontColor<> #1E5C22 + + BackgroundColor<> #F0E6FF + BorderColor<> #8A5CC2 + FontColor<> #4B2A73 + + BackgroundColor<> #FFE8CC + BorderColor<> #D8842A + FontColor<> #7A4A12 + + BackgroundColor<> #FFE5E5 + BorderColor<> #CC3333 + FontColor<> #7A1F1F +} + +state NotInitialized <> +state FatalError <> +state Terminated <> + +[*] -[#8A5CC2]-> NotInitialized + +NotInitialized -[#3A7FC4]-> Initialized : init +NotInitialized -[#CC3333]-> FatalError : command before init + +state Initialized { + + [*] -[#3A7FC4]-> Stopped + + state Stopped <> { + Stopped : volume(p) / set volume + Stopped : stop / ignore + } + + state Playing <> { + Playing : seek(p) / seek decoder + Playing : volume(p) / set volume + } + + state Paused <> { + 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 \ No newline at end of file diff --git a/scrbl/placed-player-state-model.svg b/scrbl/placed-player-state-model.svg new file mode 100644 index 0000000..fe7697f --- /dev/null +++ b/scrbl/placed-player-state-model.svg @@ -0,0 +1 @@ +Placed audio player - public state modelNotInitializedFatalErrorTerminatedInitializedStoppedvolume(p) / set volumestop / ignorePlayingseek(p) / seek decodervolume(p) / set volumePausedseek(p) / seek decodervolume(p) / set volumeopen(file)pause #tpause #faudio donestopstopopen(new-file)worker exceptionworker exceptioninitcommand before initquit / cleanup \ No newline at end of file diff --git a/scrbl/placed-player-worker-detail-model.svg b/scrbl/placed-player-worker-detail-model.svg new file mode 100644 index 0000000..29b96ac --- /dev/null +++ b/scrbl/placed-player-worker-detail-model.svg @@ -0,0 +1 @@ +Placed audio player - play command and worker detailplay command:open(new-file) while worker is activeCurrentWorkerActiveInterruptRequestedWaitingForWorkerStartingNewWorkerNewWorkerActiveopen(new-file)[feeding-audio]feed-interrupted := #tao-clear-asyncplayer-state := stoppedaudio-stopfeeding-audio = #fthread-waitaudio-openplayer-state := playingspawn workerWorker thread lifecycleWorkerIdleWorkerExitedWorkerFailedWorker activeReadingwhile paused / ao-pause #t and waitwhile playing / ao-pause #f and feed AODrainingAOMarkStoppedWorkerDoneaudio-read returns[not feed-interrupted]emit audio-donefeed-interruptedreset feed-interruptedAO closed, queue grows,or old file-idopen(file)audio-openspawn workerfeeding-audio := #tworker exitsfeeding-audio := #fexceptionemit exceptionplayer-state := stopped \ No newline at end of file diff --git a/scrbl/play-test.scrbl b/scrbl/play-test.scrbl new file mode 100644 index 0000000..7b2c2d0 --- /dev/null +++ b/scrbl/play-test.scrbl @@ -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. diff --git a/scrbl/play-workerthread-states.plantuml b/scrbl/play-workerthread-states.plantuml new file mode 100644 index 0000000..4b3347b --- /dev/null +++ b/scrbl/play-workerthread-states.plantuml @@ -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<> #DCEEFF + BorderColor<> #3A7FC4 + FontColor<> #123A63 + + BackgroundColor<> #E3F6E3 + BorderColor<> #3C9D40 + FontColor<> #1E5C22 + + BackgroundColor<> #FFE8CC + BorderColor<> #D8842A + FontColor<> #7A4A12 + + BackgroundColor<> #FFE5E5 + BorderColor<> #CC3333 + FontColor<> #7A1F1F + + BackgroundColor<> #F2F2F2 + BorderColor<> #888888 + FontColor<> #444444 +} + +state "Command loop:\nreplace active worker" as A <> { + [*] -down-> CurrentWorkerActive + + state "Current worker active" as CurrentWorkerActive <> + state "Interrupt requested" as InterruptRequested <> + state "Waiting for worker" as WaitingForWorker <> + state "Starting new worker" as StartingNewWorker <> + state "New worker active" as NewWorkerActive <> + + 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 <> { + [*] -down-> WorkerIdle + + state "WorkerIdle" as WorkerIdle <> + state "WorkerExited" as WorkerExited <> + state "WorkerFailed" as WorkerFailed <> + + WorkerIdle -down-> C : open(file)\n/ audio-open\nspawn worker + + state "Worker active" as C <> { + C : entry / feeding-audio := #t + C : exit / feeding-audio := #f + + [*] -right-> Reading + + state "Reading" as Reading <> + state "DrainingAO" as DrainingAO <> + state "MarkStopped" as MarkStopped <> + state "WorkerDone" as WorkerDone <> + + 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 \ No newline at end of file diff --git a/scrbl/racket-audio.scrbl b/scrbl/racket-audio.scrbl new file mode 100644 index 0000000..736cf50 --- /dev/null +++ b/scrbl/racket-audio.scrbl @@ -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. diff --git a/scrbl/rktplayer.svg b/scrbl/rktplayer.svg new file mode 100644 index 0000000..616e904 --- /dev/null +++ b/scrbl/rktplayer.svg @@ -0,0 +1,73 @@ + + + + + + + + + diff --git a/scrbl/taglib.scrbl b/scrbl/taglib.scrbl new file mode 100644 index 0000000..ee8f6d3 --- /dev/null +++ b/scrbl/taglib.scrbl @@ -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.