Documentation added.
This commit is contained in:
@@ -1,14 +1,79 @@
|
|||||||
# racket-audio
|
# racket-audio
|
||||||
|
|
||||||
Integration of common audio libraries in racket.
|
Integration of common audio libraries in Racket.
|
||||||
|
|
||||||
## Mac OS X
|
The package contains decoder, player and encoder bindings. Playback uses the
|
||||||
|
existing audio player modules. Encoding is provided by `audio-encoder.rkt` with
|
||||||
|
Opus and FLAC backends.
|
||||||
|
|
||||||
Make sure you have libao, libFLAC, mpg123 and ffmpeg-full installed using brew.
|
## Native dependencies
|
||||||
|
|
||||||
% brew install libao
|
For playback and decoding, install the native libraries used by the selected
|
||||||
% brew install flac
|
backends:
|
||||||
% brew install mpg123
|
|
||||||
% brew install ffmpeg-full
|
|
||||||
|
|
||||||
|
- libao
|
||||||
|
- libFLAC
|
||||||
|
- mpg123
|
||||||
|
- FFmpeg libraries, including libavutil, libavcodec, libavformat and
|
||||||
|
libswresample
|
||||||
|
|
||||||
|
For encoding, also install:
|
||||||
|
|
||||||
|
- libopusenc
|
||||||
|
- libopus
|
||||||
|
- libogg
|
||||||
|
- TagLib with the C binding, usually provided as `taglib` / `taglib_c`
|
||||||
|
|
||||||
|
The Opus encoder backend uses libopusenc directly. The FLAC encoder backend
|
||||||
|
uses libFLAC directly. FLAC sample-rate conversion uses the existing FFmpeg
|
||||||
|
swresample layer.
|
||||||
|
|
||||||
|
## macOS
|
||||||
|
|
||||||
|
Using Homebrew, install the native libraries before using the package:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
brew install libao
|
||||||
|
brew install flac
|
||||||
|
brew install mpg123
|
||||||
|
brew install ffmpeg
|
||||||
|
brew install opus
|
||||||
|
brew install libopusenc
|
||||||
|
brew install taglib
|
||||||
|
```
|
||||||
|
|
||||||
|
Some Homebrew installations provide FFmpeg as `ffmpeg`; older local setups may
|
||||||
|
use `ffmpeg-full`.
|
||||||
|
|
||||||
|
## Encoder examples
|
||||||
|
|
||||||
|
Encode to Opus:
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(require "audio-encoder.rkt")
|
||||||
|
|
||||||
|
(audio-encode "input.flac"
|
||||||
|
"output.opus"
|
||||||
|
(hash 'bitrate 224000
|
||||||
|
'vbr? #t
|
||||||
|
'complexity 10)
|
||||||
|
#:encoder 'opus)
|
||||||
|
```
|
||||||
|
|
||||||
|
Encode 96 kHz FLAC to 48 kHz FLAC:
|
||||||
|
|
||||||
|
```racket
|
||||||
|
(audio-encode "input-96k.flac"
|
||||||
|
"output-48k.flac"
|
||||||
|
(hash 'sample-rate 48000
|
||||||
|
'bits-per-sample 24
|
||||||
|
'compression-level 8)
|
||||||
|
#:encoder 'flac)
|
||||||
|
```
|
||||||
|
|
||||||
|
A small test wrapper is available in `encoder-test.rkt`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
racket encoder-test.rkt --encoder opus --input input.flac --output output.opus --bitrate-kbps 224
|
||||||
|
racket encoder-test.rkt --encoder flac --input input-96k.flac --output output-48k.flac --sample-rate 48000
|
||||||
|
```
|
||||||
|
|||||||
+132
-25
@@ -68,52 +68,148 @@
|
|||||||
(for-each (lambda (k) (hash-set! out k (hash-ref b k))) (hash-keys b)))
|
(for-each (lambda (k) (hash-set! out k (hash-ref b k))) (hash-keys b)))
|
||||||
out))
|
out))
|
||||||
|
|
||||||
|
(define (copy-hash h)
|
||||||
|
(let ((out (make-hash)))
|
||||||
|
(when (hash? h)
|
||||||
|
(for-each (lambda (k) (hash-set! out k (hash-ref h k))) (hash-keys h)))
|
||||||
|
out))
|
||||||
|
|
||||||
|
(define (maybe-string s) (and (string? s) (not (string=? s "")) s))
|
||||||
|
(define (maybe-number n) (and (number? n) (>= n 0) (number->string n)))
|
||||||
|
|
||||||
|
(define (source-tags->opus-settings input-file settings)
|
||||||
|
;; For Opus, embedded pictures must be written into the OpusTags packet
|
||||||
|
;; before the encoder starts. TagLib post-processing is not reliable for
|
||||||
|
;; this path, so transfer the regular comments and cover art through
|
||||||
|
;; libopusenc comments instead.
|
||||||
|
(with-handlers ([exn:fail? (lambda (e)
|
||||||
|
(warn-sound "Could not read source tags from ~a for Opus comments: ~a"
|
||||||
|
input-file (exn-message e))
|
||||||
|
settings)])
|
||||||
|
(call-with-id3-tags
|
||||||
|
input-file
|
||||||
|
(lambda (src)
|
||||||
|
(if (not (tags-valid? src))
|
||||||
|
settings
|
||||||
|
(let ((out (copy-hash settings))
|
||||||
|
(comments (make-hash)))
|
||||||
|
(let ((title (maybe-string (tags-title src)))) (when title (hash-set! comments 'title title)))
|
||||||
|
(let ((album (maybe-string (tags-album src)))) (when album (hash-set! comments 'album album)))
|
||||||
|
(let ((artist (maybe-string (tags-artist src)))) (when artist (hash-set! comments 'artist artist)))
|
||||||
|
(let ((comment (maybe-string (tags-comment src)))) (when comment (hash-set! comments 'comment comment)))
|
||||||
|
(let ((genre (maybe-string (tags-genre src)))) (when genre (hash-set! comments 'genre genre)))
|
||||||
|
(let ((composer (maybe-string (tags-composer src)))) (when composer (hash-set! comments 'composer composer)))
|
||||||
|
(let ((album-artist (maybe-string (tags-album-artist src)))) (when album-artist (hash-set! comments 'albumartist album-artist)))
|
||||||
|
(let ((year (maybe-number (tags-year src)))) (when year (hash-set! comments 'date year)))
|
||||||
|
(let ((track (maybe-number (tags-track src)))) (when track (hash-set! comments 'tracknumber track)))
|
||||||
|
(let ((disc (tags-disc-number src)))
|
||||||
|
(cond [(string? disc) (unless (string=? disc "") (hash-set! comments 'discnumber disc))]
|
||||||
|
[(and (number? disc) (>= disc 0)) (hash-set! comments 'discnumber (number->string disc))]
|
||||||
|
[else (void)]))
|
||||||
|
(unless (null? (hash-keys comments)) (hash-set! out 'comments comments))
|
||||||
|
(let ((picture (tags-picture src)))
|
||||||
|
(unless (eq? picture #f) (hash-set! out 'picture picture)))
|
||||||
|
out)))
|
||||||
|
#:mode 'read)))
|
||||||
|
|
||||||
|
(define (make-tag-result method success? picture note)
|
||||||
|
(let ((h (make-hash)))
|
||||||
|
(hash-set! h 'method method)
|
||||||
|
(hash-set! h 'success? success?)
|
||||||
|
(hash-set! h 'picture? (not (eq? picture #f)))
|
||||||
|
(when (id3-picture? picture)
|
||||||
|
(hash-set! h 'picture-size (id3-picture-size picture))
|
||||||
|
(hash-set! h 'picture-mimetype (id3-picture-mimetype picture)))
|
||||||
|
(when note (hash-set! h 'note note))
|
||||||
|
h))
|
||||||
|
|
||||||
(define (copy-tags! input-file output-file)
|
(define (copy-tags! input-file output-file)
|
||||||
(with-handlers ([exn:fail? (lambda (e)
|
(with-handlers ([exn:fail? (lambda (e)
|
||||||
(warn-sound "Could not copy tags from ~a to ~a: ~a"
|
(warn-sound "Could not copy tags from ~a to ~a: ~a"
|
||||||
input-file output-file (exn-message e))
|
input-file output-file (exn-message e))
|
||||||
#f)])
|
(make-tag-result 'taglib-post-copy #f #f (exn-message e)))])
|
||||||
(call-with-id3-tags
|
(call-with-id3-tags
|
||||||
input-file
|
input-file
|
||||||
(lambda (src)
|
(lambda (src)
|
||||||
(call-with-id3-tags
|
(call-with-id3-tags
|
||||||
output-file
|
output-file
|
||||||
(lambda (dst)
|
(lambda (dst)
|
||||||
(when (and (tags-valid? src) (tags-valid? dst))
|
(if (and (tags-valid? src) (tags-valid? dst))
|
||||||
(tag-value-copy! src dst tags-title tags-title! empty-string?)
|
(begin
|
||||||
(tag-value-copy! src dst tags-album tags-album! empty-string?)
|
(tag-value-copy! src dst tags-title tags-title! empty-string?)
|
||||||
(tag-value-copy! src dst tags-artist tags-artist! empty-string?)
|
(tag-value-copy! src dst tags-album tags-album! empty-string?)
|
||||||
(tag-value-copy! src dst tags-comment tags-comment! empty-string?)
|
(tag-value-copy! src dst tags-artist tags-artist! empty-string?)
|
||||||
(tag-value-copy! src dst tags-genre tags-genre! empty-string?)
|
(tag-value-copy! src dst tags-comment tags-comment! empty-string?)
|
||||||
(tag-value-copy! src dst tags-composer tags-composer! empty-string?)
|
(tag-value-copy! src dst tags-genre tags-genre! empty-string?)
|
||||||
(tag-value-copy! src dst tags-album-artist tags-album-artist! empty-string?)
|
(tag-value-copy! src dst tags-composer tags-composer! empty-string?)
|
||||||
(tag-value-copy! src dst tags-year tags-year! empty-number?)
|
(tag-value-copy! src dst tags-album-artist tags-album-artist! empty-string?)
|
||||||
(tag-value-copy! src dst tags-track tags-track! empty-number?)
|
(tag-value-copy! src dst tags-year tags-year! empty-number?)
|
||||||
(tag-value-copy! src dst tags-disc-number tags-disc-number! empty-number?)
|
(tag-value-copy! src dst tags-track tags-track! empty-number?)
|
||||||
(let ((picture (tags-picture src)))
|
(tag-value-copy! src dst tags-disc-number tags-disc-number! empty-number?)
|
||||||
(unless (eq? picture #f) (tags-picture! dst picture)))
|
(let ((picture (tags-picture src)))
|
||||||
(tags-save! dst)))
|
(unless (eq? picture #f) (tags-picture! dst picture))
|
||||||
|
(tags-save! dst)
|
||||||
|
(make-tag-result 'taglib-post-copy #t picture #f)))
|
||||||
|
(make-tag-result 'taglib-post-copy #f #f "source or destination tags invalid")))
|
||||||
#:mode 'read-write))
|
#:mode 'read-write))
|
||||||
#:mode 'read)
|
#:mode 'read)))
|
||||||
#t))
|
|
||||||
|
(define (input-frames-in-buffer fmt buf-len)
|
||||||
|
(let* ((channels (hash-ref fmt 'channels 1))
|
||||||
|
(bits (hash-ref fmt 'bits-per-sample (hash-ref fmt 'pcm-bits-per-sample 16)))
|
||||||
|
(bytes-per-sample (max 1 (quotient bits 8)))
|
||||||
|
(frame-bytes (* channels bytes-per-sample)))
|
||||||
|
(if (> frame-bytes 0) (quotient buf-len frame-bytes) 0)))
|
||||||
|
|
||||||
|
(define (total-input-frames fmt)
|
||||||
|
(and (hash? fmt)
|
||||||
|
(or (hash-ref fmt 'total-samples #f)
|
||||||
|
(hash-ref fmt 'total-frames #f)
|
||||||
|
(hash-ref fmt 'frames #f))))
|
||||||
|
|
||||||
(define (audio-encode input-file output-file settings
|
(define (audio-encode input-file output-file settings
|
||||||
#:encoder [explicit-kind #f]
|
#:encoder [explicit-kind #f]
|
||||||
#:copy-tags? [copy-tags? #t])
|
#:copy-tags? [copy-tags? #t]
|
||||||
|
#:progress-callback [progress-callback #f])
|
||||||
(define-values (kind encoder) (encoder-for-output output-file explicit-kind))
|
(define-values (kind encoder) (encoder-for-output output-file explicit-kind))
|
||||||
|
(define effective-settings (if (and copy-tags? (eq? kind 'opus))
|
||||||
|
(source-tags->opus-settings input-file settings)
|
||||||
|
settings))
|
||||||
(define backend-handle #f)
|
(define backend-handle #f)
|
||||||
(define format #f)
|
(define format #f)
|
||||||
(define output-format #f)
|
(define output-format #f)
|
||||||
(define converter #f)
|
(define converter #f)
|
||||||
(define frames-written 0)
|
(define frames-written 0)
|
||||||
|
(define frames-read 0)
|
||||||
|
(define last-progress -1.0)
|
||||||
|
(define tags-result #f)
|
||||||
|
|
||||||
|
(define (progress! phase input-format)
|
||||||
|
(when progress-callback
|
||||||
|
(let* ((total (total-input-frames input-format))
|
||||||
|
(progress (and (integer? total) (> total 0)
|
||||||
|
(min 1.0 (/ frames-read total))))
|
||||||
|
(h (make-hash)))
|
||||||
|
(hash-set! h 'phase phase)
|
||||||
|
(hash-set! h 'encoder kind)
|
||||||
|
(hash-set! h 'input input-file)
|
||||||
|
(hash-set! h 'output output-file)
|
||||||
|
(hash-set! h 'frames-read frames-read)
|
||||||
|
(hash-set! h 'frames-written frames-written)
|
||||||
|
(hash-set! h 'total-frames total)
|
||||||
|
(hash-set! h 'progress progress)
|
||||||
|
(hash-set! h 'input-format input-format)
|
||||||
|
(when output-format (hash-set! h 'output-format output-format))
|
||||||
|
(progress-callback h)
|
||||||
|
(when (number? progress) (set! last-progress progress)))))
|
||||||
|
|
||||||
(define (ensure-open! fmt)
|
(define (ensure-open! fmt)
|
||||||
(when (eq? backend-handle #f)
|
(when (eq? backend-handle #f)
|
||||||
;; Record the resolved output format, not merely the incoming PCM format.
|
;; Record the resolved output format, not merely the incoming PCM format.
|
||||||
;; This matters when only FLAC bit depth changes, because no swresample
|
;; This matters when only FLAC bit depth changes, because no swresample
|
||||||
;; converter is needed but the resulting FLAC stream metadata still differs.
|
;; converter is needed but the resulting FLAC stream metadata still differs.
|
||||||
(set! output-format ((audio-encoder-settings encoder) settings fmt))
|
(set! output-format ((audio-encoder-settings encoder) effective-settings fmt))
|
||||||
(set! backend-handle ((audio-encoder-open encoder) output-file settings fmt))))
|
(set! backend-handle ((audio-encoder-open encoder) output-file effective-settings fmt))))
|
||||||
|
|
||||||
(define (write-backend! fmt buffer buf-len)
|
(define (write-backend! fmt buffer buf-len)
|
||||||
(ensure-open! fmt)
|
(ensure-open! fmt)
|
||||||
@@ -125,8 +221,8 @@
|
|||||||
;; converter by default: libopusenc accepts the source input rate and has
|
;; converter by default: libopusenc accepts the source input rate and has
|
||||||
;; its own resampler, and opus-encoder.rkt feeds it float PCM directly.
|
;; its own resampler, and opus-encoder.rkt feeds it float PCM directly.
|
||||||
(when (and (eq? kind 'flac) (eq? converter #f))
|
(when (and (eq? kind 'flac) (eq? converter #f))
|
||||||
(when (pcm-conversion-needed? input-format settings)
|
(when (pcm-conversion-needed? input-format effective-settings)
|
||||||
(set! converter (make-pcm-converter input-format settings)))))
|
(set! converter (make-pcm-converter input-format effective-settings)))))
|
||||||
|
|
||||||
(define (write-converted! input-format buffer buf-len)
|
(define (write-converted! input-format buffer buf-len)
|
||||||
(ensure-converter! input-format)
|
(ensure-converter! input-format)
|
||||||
@@ -148,12 +244,15 @@
|
|||||||
;; Keep stream metadata, but delay encoder creation until the first audio
|
;; Keep stream metadata, but delay encoder creation until the first audio
|
||||||
;; buffer. Some decoders report an output-oriented stream format first
|
;; buffer. Some decoders report an output-oriented stream format first
|
||||||
;; and then the exact PCM frame format in buf-info.
|
;; and then the exact PCM frame format in buf-info.
|
||||||
(set! format fmt))
|
(set! format fmt)
|
||||||
|
(progress! 'format fmt))
|
||||||
|
|
||||||
(define (on-audio audio-kind ao-kind handle buf-info buffer buf-len)
|
(define (on-audio audio-kind ao-kind handle buf-info buffer buf-len)
|
||||||
(let ((effective-format (merge-hash format buf-info)))
|
(let ((effective-format (merge-hash format buf-info)))
|
||||||
(set! format effective-format)
|
(set! format effective-format)
|
||||||
(write-converted! effective-format buffer buf-len)))
|
(set! frames-read (+ frames-read (input-frames-in-buffer effective-format buf-len)))
|
||||||
|
(write-converted! effective-format buffer buf-len)
|
||||||
|
(progress! 'audio effective-format)))
|
||||||
|
|
||||||
(let* ((audio-open-proc (dynamic-require audio-decoder-module 'audio-open))
|
(let* ((audio-open-proc (dynamic-require audio-decoder-module 'audio-open))
|
||||||
(audio-read-proc (dynamic-require audio-decoder-module 'audio-read))
|
(audio-read-proc (dynamic-require audio-decoder-module 'audio-read))
|
||||||
@@ -167,14 +266,22 @@
|
|||||||
(lambda () (when backend-handle ((audio-encoder-finish encoder) backend-handle)))
|
(lambda () (when backend-handle ((audio-encoder-finish encoder) backend-handle)))
|
||||||
(lambda () (when converter (pcm-converter-close! converter)))))))
|
(lambda () (when converter (pcm-converter-close! converter)))))))
|
||||||
|
|
||||||
(when copy-tags? (copy-tags! input-file output-file))
|
(progress! 'finished-encoding format)
|
||||||
|
(set! tags-result
|
||||||
|
(cond [(not copy-tags?) (make-tag-result 'none #t #f "tag copy disabled")]
|
||||||
|
[(eq? kind 'opus)
|
||||||
|
(make-tag-result 'libopusenc-comments #t (hash-ref effective-settings 'picture #f) #f)]
|
||||||
|
[else (copy-tags! input-file output-file)]))
|
||||||
|
(progress! 'finished format)
|
||||||
(let ((r (make-hash)))
|
(let ((r (make-hash)))
|
||||||
(hash-set! r 'encoder kind)
|
(hash-set! r 'encoder kind)
|
||||||
(hash-set! r 'input input-file)
|
(hash-set! r 'input input-file)
|
||||||
(hash-set! r 'output output-file)
|
(hash-set! r 'output output-file)
|
||||||
(hash-set! r 'input-format format)
|
(hash-set! r 'input-format format)
|
||||||
(hash-set! r 'output-format output-format)
|
(hash-set! r 'output-format output-format)
|
||||||
|
(hash-set! r 'frames-read frames-read)
|
||||||
(hash-set! r 'frames-written frames-written)
|
(hash-set! r 'frames-written frames-written)
|
||||||
|
(hash-set! r 'tag-copy tags-result)
|
||||||
r))
|
r))
|
||||||
|
|
||||||
) ; end of module
|
) ; end of module
|
||||||
|
|||||||
+31
-2
@@ -58,7 +58,18 @@
|
|||||||
(hash-ref fmt 'sample-rate "?")
|
(hash-ref fmt 'sample-rate "?")
|
||||||
(hash-ref fmt 'channels "?")
|
(hash-ref fmt 'channels "?")
|
||||||
(hash-ref fmt 'bits-per-sample "?")
|
(hash-ref fmt 'bits-per-sample "?")
|
||||||
(hash-ref fmt 'total-frames "?"))
|
(hash-ref fmt 'total-frames (hash-ref fmt 'total-samples "?")))
|
||||||
|
"unknown"))
|
||||||
|
|
||||||
|
(define (tag-summary tag-copy)
|
||||||
|
(if (hash? tag-copy)
|
||||||
|
(format "method=~a, success=~a, picture=~a~a"
|
||||||
|
(hash-ref tag-copy 'method "?")
|
||||||
|
(hash-ref tag-copy 'success? "?")
|
||||||
|
(hash-ref tag-copy 'picture? #f)
|
||||||
|
(let ((size (hash-ref tag-copy 'picture-size #f))
|
||||||
|
(mt (hash-ref tag-copy 'picture-mimetype #f)))
|
||||||
|
(if size (format ", ~a bytes, ~a" size mt) "")))
|
||||||
"unknown"))
|
"unknown"))
|
||||||
|
|
||||||
(define (display-result result)
|
(define (display-result result)
|
||||||
@@ -68,11 +79,26 @@
|
|||||||
(displayln (format "encoder : ~a" (hash-ref result 'encoder '?)))
|
(displayln (format "encoder : ~a" (hash-ref result 'encoder '?)))
|
||||||
(displayln (format "input : ~a" (hash-ref result 'input '?)))
|
(displayln (format "input : ~a" (hash-ref result 'input '?)))
|
||||||
(displayln (format "output : ~a" (hash-ref result 'output '?)))
|
(displayln (format "output : ~a" (hash-ref result 'output '?)))
|
||||||
|
(displayln (format "frames read : ~a" (hash-ref result 'frames-read '?)))
|
||||||
(displayln (format "frames written : ~a" (hash-ref result 'frames-written '?)))
|
(displayln (format "frames written : ~a" (hash-ref result 'frames-written '?)))
|
||||||
(displayln (format "input format : ~a" (format-summary (hash-ref result 'input-format #f))))
|
(displayln (format "input format : ~a" (format-summary (hash-ref result 'input-format #f))))
|
||||||
(displayln (format "output format : ~a" (format-summary (hash-ref result 'output-format #f))))
|
(displayln (format "output format : ~a" (format-summary (hash-ref result 'output-format #f))))
|
||||||
|
(displayln (format "tag copy : ~a" (tag-summary (hash-ref result 'tag-copy #f))))
|
||||||
result)
|
result)
|
||||||
|
|
||||||
|
(define (make-progress-callback)
|
||||||
|
(define last-pct -1)
|
||||||
|
(lambda (h)
|
||||||
|
(let ((p (hash-ref h 'progress #f)))
|
||||||
|
(when (number? p)
|
||||||
|
(let ((pct (inexact->exact (round (* 100 p)))))
|
||||||
|
(when (not (= pct last-pct))
|
||||||
|
(set! last-pct pct)
|
||||||
|
(printf "\rprogress : ~a%" pct)
|
||||||
|
(flush-output))
|
||||||
|
(when (or (>= pct 100) (eq? (hash-ref h 'phase #f) 'finished))
|
||||||
|
(newline)))))))
|
||||||
|
|
||||||
(define (encoder-test input-file output-file encoder settings #:copy-tags? [copy-tags? #t])
|
(define (encoder-test input-file output-file encoder settings #:copy-tags? [copy-tags? #t])
|
||||||
(let* ((enc (encoder-symbol encoder))
|
(let* ((enc (encoder-symbol encoder))
|
||||||
(out (if output-file output-file (default-output-file enc))))
|
(out (if output-file output-file (default-output-file enc))))
|
||||||
@@ -81,7 +107,10 @@
|
|||||||
(displayln (format " -> ~a" out))
|
(displayln (format " -> ~a" out))
|
||||||
(displayln (format "encoder : ~a" enc))
|
(displayln (format "encoder : ~a" enc))
|
||||||
(displayln (format "settings: ~a" settings))
|
(displayln (format "settings: ~a" settings))
|
||||||
(display-result (audio-encode input-file out settings #:encoder enc #:copy-tags? copy-tags?))))
|
(display-result (audio-encode input-file out settings
|
||||||
|
#:encoder enc
|
||||||
|
#:copy-tags? copy-tags?
|
||||||
|
#:progress-callback (make-progress-callback)))))
|
||||||
|
|
||||||
(define (encoder-test-opus [input-file test-file3]
|
(define (encoder-test-opus [input-file test-file3]
|
||||||
[output-file #f]
|
[output-file #f]
|
||||||
|
|||||||
+52
-1
@@ -1,7 +1,9 @@
|
|||||||
(module opus-encoder racket/base
|
(module opus-encoder racket/base
|
||||||
|
|
||||||
(require ffi/unsafe
|
(require ffi/unsafe
|
||||||
"private/utils.rkt")
|
racket/string
|
||||||
|
"private/utils.rkt"
|
||||||
|
"taglib.rkt")
|
||||||
|
|
||||||
(provide opus-encoder-available?
|
(provide opus-encoder-available?
|
||||||
opus-encoder-default-settings
|
opus-encoder-default-settings
|
||||||
@@ -156,6 +158,54 @@
|
|||||||
(check-ope 'opus-comment (ope_comments_add comments (string-upcase (symbol->string k)) v)))))
|
(check-ope 'opus-comment (ope_comments_add comments (string-upcase (symbol->string k)) v)))))
|
||||||
(hash-keys ch))))))
|
(hash-keys ch))))))
|
||||||
|
|
||||||
|
(define (picture-kind->opus-int kind)
|
||||||
|
(define s
|
||||||
|
(cond [(number? kind) (number->string kind)]
|
||||||
|
[(symbol? kind) (string-replace (string-downcase (symbol->string kind)) "-" " ")]
|
||||||
|
[(string? kind) (string-downcase kind)]
|
||||||
|
[else ""]))
|
||||||
|
(cond [(or (string=? s "0") (string=? s "other")) 0]
|
||||||
|
[(or (string=? s "1") (string=? s "file icon") (string=? s "32x32 icon")) 1]
|
||||||
|
[(or (string=? s "2") (string=? s "other file icon")) 2]
|
||||||
|
[(or (string=? s "3") (string=? s "front cover") (string=? s "cover front")
|
||||||
|
(string=? s "cover (front)") (string=? s "front")) 3]
|
||||||
|
[(or (string=? s "4") (string=? s "back cover") (string=? s "cover back")
|
||||||
|
(string=? s "cover (back)") (string=? s "back")) 4]
|
||||||
|
[(or (string=? s "5") (string=? s "leaflet page")) 5]
|
||||||
|
[(or (string=? s "6") (string=? s "media") (string=? s "label side of media")) 6]
|
||||||
|
[(or (string=? s "7") (string=? s "lead artist") (string=? s "lead performer")
|
||||||
|
(string=? s "soloist")) 7]
|
||||||
|
[(or (string=? s "8") (string=? s "artist") (string=? s "performer")) 8]
|
||||||
|
[(or (string=? s "9") (string=? s "conductor")) 9]
|
||||||
|
[(or (string=? s "10") (string=? s "band") (string=? s "orchestra")) 10]
|
||||||
|
[(or (string=? s "11") (string=? s "composer")) 11]
|
||||||
|
[(or (string=? s "12") (string=? s "lyricist") (string=? s "text writer")) 12]
|
||||||
|
[(or (string=? s "13") (string=? s "recording location")) 13]
|
||||||
|
[(or (string=? s "14") (string=? s "during recording")) 14]
|
||||||
|
[(or (string=? s "15") (string=? s "during performance")) 15]
|
||||||
|
[(or (string=? s "16") (string=? s "movie screen capture")) 16]
|
||||||
|
[(or (string=? s "17") (string=? s "a bright coloured fish")
|
||||||
|
(string=? s "bright coloured fish")) 17]
|
||||||
|
[(or (string=? s "18") (string=? s "illustration")) 18]
|
||||||
|
[(or (string=? s "19") (string=? s "band logo") (string=? s "artist logotype")) 19]
|
||||||
|
[(or (string=? s "20") (string=? s "publisher logo") (string=? s "publisher logotype")) 20]
|
||||||
|
[else 3]))
|
||||||
|
|
||||||
|
(define (add-picture! comments settings)
|
||||||
|
(when (hash-has-key? settings 'picture)
|
||||||
|
(unless ope_comments_add_picture_from_memory
|
||||||
|
(error 'opus-picture "libopusenc does not provide ope_comments_add_picture_from_memory"))
|
||||||
|
(let ((picture (hash-ref settings 'picture)))
|
||||||
|
(when (id3-picture? picture)
|
||||||
|
(let ((data (id3-picture-bytes picture)))
|
||||||
|
(check-ope 'opus-picture
|
||||||
|
(ope_comments_add_picture_from_memory
|
||||||
|
comments
|
||||||
|
data
|
||||||
|
(bytes-length data)
|
||||||
|
(picture-kind->opus-int (id3-picture-kind picture))
|
||||||
|
(id3-picture-description picture))))))))
|
||||||
|
|
||||||
(define (opus-encoder-open output-file settings format)
|
(define (opus-encoder-open output-file settings format)
|
||||||
(unless (opus-encoder-available?)
|
(unless (opus-encoder-available?)
|
||||||
(error 'opus-encoder-open "libopusenc or one of its dependent libraries (ogg/opus) could not be loaded"))
|
(error 'opus-encoder-open "libopusenc or one of its dependent libraries (ogg/opus) could not be loaded"))
|
||||||
@@ -163,6 +213,7 @@
|
|||||||
(resolved (opus-encoder-prepare-settings settings format))
|
(resolved (opus-encoder-prepare-settings settings format))
|
||||||
(comments (ope_comments_create)))
|
(comments (ope_comments_create)))
|
||||||
(add-comments! comments resolved)
|
(add-comments! comments resolved)
|
||||||
|
(add-picture! comments resolved)
|
||||||
(let-values (((enc err) (ope_encoder_create_file file comments
|
(let-values (((enc err) (ope_encoder_create_file file comments
|
||||||
(hash-ref resolved 'sample-rate)
|
(hash-ref resolved 'sample-rate)
|
||||||
(hash-ref resolved 'channels)
|
(hash-ref resolved 'channels)
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
#lang scribble/manual
|
||||||
|
|
||||||
|
@(require (for-label racket/base
|
||||||
|
racket/contract
|
||||||
|
racket/path
|
||||||
|
"../audio-encoder.rkt"))
|
||||||
|
|
||||||
|
@title{Audio Encoding}
|
||||||
|
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
|
||||||
|
|
||||||
|
@defmodule[racket-audio/audio-encoder]
|
||||||
|
|
||||||
|
The @racketmodname[racket-audio/audio-encoder] module provides the high level
|
||||||
|
file-to-file encoding pipeline. It reuses the existing decoder environment to
|
||||||
|
read the input file and sends the decoded PCM stream to a selected encoder
|
||||||
|
backend. The built-in backends are Opus, implemented with @tt{libopusenc}, and
|
||||||
|
FLAC, implemented with @tt{libFLAC}.
|
||||||
|
|
||||||
|
This module is intended as the public encoding API. The concrete backend
|
||||||
|
modules are small FFI backends; applications normally call @racket[audio-encode]
|
||||||
|
instead of using those modules directly.
|
||||||
|
|
||||||
|
@section{Pipeline}
|
||||||
|
|
||||||
|
Encoding is organised as a streaming pipeline:
|
||||||
|
|
||||||
|
@racketblock[
|
||||||
|
input file
|
||||||
|
;; decoded by audio-decoder.rkt
|
||||||
|
-> PCM buffers
|
||||||
|
;; optional conversion for FLAC
|
||||||
|
-> encoder backend
|
||||||
|
-> output file]
|
||||||
|
|
||||||
|
The encoder is selected from @racket[#:encoder] or, when that argument is not
|
||||||
|
provided, from the output filename extension. The initial built-in encoders are
|
||||||
|
@racket['opus] for @filepath{.opus} and @filepath{.oga} files, and
|
||||||
|
@racket['flac] for @filepath{.flac} files.
|
||||||
|
|
||||||
|
The PCM stream is not collected in memory. Each decoded buffer is forwarded to
|
||||||
|
the selected backend. FLAC encoding may insert a PCM conversion step when the
|
||||||
|
settings request a different sample rate, channel count, or bit depth. Opus
|
||||||
|
encoding feeds floating-point PCM to @tt{libopusenc}; sample-rate conversion for
|
||||||
|
Opus is left to @tt{libopusenc}.
|
||||||
|
|
||||||
|
@section{Encoding a file}
|
||||||
|
|
||||||
|
@defproc[(audio-encode [input-file path-string?]
|
||||||
|
[output-file path-string?]
|
||||||
|
[settings hash?]
|
||||||
|
[#:encoder encoder (or/c symbol? #f) #f]
|
||||||
|
[#:copy-tags? copy-tags? boolean? #t]
|
||||||
|
[#:progress-callback progress-callback
|
||||||
|
(or/c procedure? #f) #f])
|
||||||
|
hash?]{
|
||||||
|
Encodes @racket[input-file] to @racket[output-file] and returns a result hash.
|
||||||
|
The @racket[settings] hash is interpreted by the selected backend.
|
||||||
|
|
||||||
|
When @racket[encoder] is @racket[#f], the backend is inferred from the output
|
||||||
|
file extension. Pass @racket['opus] or @racket['flac] to force a backend.
|
||||||
|
|
||||||
|
When @racket[copy-tags?] is true, common textual tags and an embedded picture
|
||||||
|
are copied from the source file to the destination file. Opus comments and
|
||||||
|
cover art are written before encoding starts through @tt{libopusenc}. FLAC
|
||||||
|
metadata is copied after the encoded file has been written, using the TagLib
|
||||||
|
wrapper.
|
||||||
|
|
||||||
|
When @racket[progress-callback] is a procedure, it is called with a progress
|
||||||
|
hash during encoding. Progress is based on the number of input frames read from
|
||||||
|
the decoder, not on the number of frames written by the encoder. This matters
|
||||||
|
for resampling, because output frame counts can differ from input frame counts.}
|
||||||
|
|
||||||
|
@racketblock[
|
||||||
|
(audio-encode "input.flac"
|
||||||
|
"output.opus"
|
||||||
|
(hash 'bitrate 224000
|
||||||
|
'vbr? #t
|
||||||
|
'complexity 10)
|
||||||
|
#:encoder 'opus)
|
||||||
|
|
||||||
|
(audio-encode "input-96k.flac"
|
||||||
|
"output-48k.flac"
|
||||||
|
(hash 'sample-rate 48000
|
||||||
|
'bits-per-sample 24
|
||||||
|
'compression-level 8)
|
||||||
|
#:encoder 'flac)]
|
||||||
|
|
||||||
|
@section{Result hash}
|
||||||
|
|
||||||
|
The result hash contains the following keys:
|
||||||
|
|
||||||
|
@itemlist[#:style 'compact
|
||||||
|
@item{@racket['encoder], the selected backend symbol;}
|
||||||
|
@item{@racket['input] and @racket['output], the source and destination paths;}
|
||||||
|
@item{@racket['input-format], the final decoded input format hash seen by the
|
||||||
|
pipeline;}
|
||||||
|
@item{@racket['output-format], the resolved backend output format hash;}
|
||||||
|
@item{@racket['frames-read], the number of input frames consumed;}
|
||||||
|
@item{@racket['frames-written], the number of frames accepted by the backend;}
|
||||||
|
@item{@racket['tag-copy], a hash describing how metadata was handled.}]
|
||||||
|
|
||||||
|
The @racket['tag-copy] hash contains a @racket['method] key. For Opus the
|
||||||
|
method is @racket['libopusenc-comments], because metadata must be supplied to
|
||||||
|
@tt{libopusenc} before the encoder writes the OpusTags packet. For FLAC the
|
||||||
|
method is @racket['taglib-post-copy], because the encoded file is tagged after
|
||||||
|
encoding.
|
||||||
|
|
||||||
|
@section{Progress callback}
|
||||||
|
|
||||||
|
The progress callback receives a hash with at least these keys:
|
||||||
|
|
||||||
|
@itemlist[#:style 'compact
|
||||||
|
@item{@racket['phase], such as @racket['format], @racket['audio],
|
||||||
|
@racket['finished-encoding], or @racket['finished];}
|
||||||
|
@item{@racket['frames-read] and @racket['frames-written];}
|
||||||
|
@item{@racket['total-frames], when the decoder reported a known input length;}
|
||||||
|
@item{@racket['progress], a number between @racket[0.0] and @racket[1.0] when
|
||||||
|
@racket['total-frames] is known, otherwise @racket[#f];}
|
||||||
|
@item{@racket['input-format] and, after the backend has opened,
|
||||||
|
@racket['output-format].}]
|
||||||
|
|
||||||
|
A simple command-line style progress callback can print a percentage on one
|
||||||
|
line:
|
||||||
|
|
||||||
|
@racketblock[
|
||||||
|
(define (show-progress h)
|
||||||
|
(let ((p (hash-ref h 'progress #f)))
|
||||||
|
(when (number? p)
|
||||||
|
(printf "\rprogress: ~a%" (round (* 100 p)))
|
||||||
|
(flush-output))))]
|
||||||
|
|
||||||
|
@section{Opus settings}
|
||||||
|
|
||||||
|
The Opus backend uses @tt{libopusenc}. The input PCM is converted to interleaved
|
||||||
|
floating-point samples in the range @racket[-1.0] to @racket[1.0] and written
|
||||||
|
with @tt{ope_encoder_write_float}. The source sample rate is passed to
|
||||||
|
@tt{libopusenc}; @tt{libopusenc} performs the required internal resampling for
|
||||||
|
Opus output.
|
||||||
|
|
||||||
|
The following settings are recognised:
|
||||||
|
|
||||||
|
@itemlist[#:style 'compact
|
||||||
|
@item{@racket['bitrate], bitrate in bits per second. The default is
|
||||||
|
@racket[160000].}
|
||||||
|
@item{@racket['vbr?], whether variable bitrate is enabled. The default is
|
||||||
|
@racket[#t].}
|
||||||
|
@item{@racket['constrained-vbr?], whether constrained VBR is enabled. The
|
||||||
|
default is @racket[#f].}
|
||||||
|
@item{@racket['complexity], encoder complexity. The default is @racket[10].}
|
||||||
|
@item{@racket['comment-padding], Opus comment padding in bytes. The default
|
||||||
|
is @racket[512].}
|
||||||
|
@item{@racket['signal], optionally @racket['auto], @racket['voice], or
|
||||||
|
@racket['music].}
|
||||||
|
@item{@racket['lsb-depth], optionally passed to the encoder as the source
|
||||||
|
least significant bit depth.}
|
||||||
|
@item{@racket['comments], an optional hash of Opus comment strings. When
|
||||||
|
@racket[#:copy-tags?] is true, @racket[audio-encode] fills this from the
|
||||||
|
source tags.}
|
||||||
|
@item{@racket['picture], an optional picture value from @racketmodname[racket-audio/taglib].
|
||||||
|
When @racket[#:copy-tags?] is true, @racket[audio-encode] fills this
|
||||||
|
from the source tags.}]
|
||||||
|
|
||||||
|
The first backend version supports mono and stereo input.
|
||||||
|
|
||||||
|
@section{FLAC settings}
|
||||||
|
|
||||||
|
The FLAC backend uses the @tt{libFLAC} stream encoder. It writes interleaved
|
||||||
|
integer PCM samples through the FLAC encoder API. When the requested output
|
||||||
|
format differs from the decoded input format, @racketmodname[racket-audio/private/pcm-converter]
|
||||||
|
uses the existing FFmpeg @tt{swresample} layer from
|
||||||
|
@racketmodname[racket-audio/ffmpeg-definitions] to perform PCM normalisation.
|
||||||
|
|
||||||
|
The following settings are recognised:
|
||||||
|
|
||||||
|
@itemlist[#:style 'compact
|
||||||
|
@item{@racket['compression-level], FLAC compression level. The default is
|
||||||
|
@racket[5].}
|
||||||
|
@item{@racket['verify?], whether the FLAC encoder verifies encoded output. The
|
||||||
|
default is @racket[#f].}
|
||||||
|
@item{@racket['blocksize], explicit FLAC block size. The default is
|
||||||
|
@racket[0], meaning the library default.}
|
||||||
|
@item{@racket['sample-rate] or @racket['target-sample-rate], target sample rate
|
||||||
|
in Hz. Use @racket['source] or omit the key to keep the source rate.}
|
||||||
|
@item{@racket['channels] or @racket['target-channels], target channel count.
|
||||||
|
Use @racket['source] or omit the key to keep the source channel count.}
|
||||||
|
@item{@racket['bits-per-sample] or @racket['target-bits-per-sample], target
|
||||||
|
bit depth. Use @racket['source] or omit the key to keep the source bit
|
||||||
|
depth.}]
|
||||||
|
|
||||||
|
For example, a 24-bit 96 kHz FLAC file can be transcoded to 24-bit 48 kHz FLAC
|
||||||
|
with:
|
||||||
|
|
||||||
|
@racketblock[
|
||||||
|
(audio-encode "input-96k.flac"
|
||||||
|
"output-48k.flac"
|
||||||
|
(hash 'sample-rate 48000
|
||||||
|
'bits-per-sample 24
|
||||||
|
'compression-level 8)
|
||||||
|
#:encoder 'flac)]
|
||||||
|
|
||||||
|
@section{Encoder registration}
|
||||||
|
|
||||||
|
@defproc[(audio-supported-encoder-extensions) (listof string?)]{
|
||||||
|
Returns the extensions supported by the currently registered encoders. The
|
||||||
|
initial list includes @racket["flac"], @racket["opus"], and @racket["oga"].}
|
||||||
|
|
||||||
|
@defproc[(make-audio-encoder [exts (listof string?)]
|
||||||
|
[open procedure?]
|
||||||
|
[write procedure?]
|
||||||
|
[finish procedure?]
|
||||||
|
[settings procedure?])
|
||||||
|
audio-encoder?]{
|
||||||
|
Creates an encoder descriptor. The descriptor is used by
|
||||||
|
@racket[audio-register-encoder!] to register a backend.
|
||||||
|
|
||||||
|
The @racket[open] procedure receives the output file, settings hash, and input
|
||||||
|
format hash. The @racket[write] procedure receives the backend handle, buffer
|
||||||
|
format hash, byte buffer, and byte length, and returns the number of frames
|
||||||
|
accepted by the backend. The @racket[finish] procedure finalises and releases
|
||||||
|
the backend handle. The @racket[settings] procedure resolves backend defaults
|
||||||
|
against the input format and returns the output format hash.}
|
||||||
|
|
||||||
|
@defproc[(audio-encoder? [v any/c]) boolean?]{
|
||||||
|
Returns @racket[#t] when @racket[v] is an encoder descriptor.}
|
||||||
|
|
||||||
|
@defproc[(audio-register-encoder! [type symbol?]
|
||||||
|
[encoder audio-encoder?])
|
||||||
|
void?]{
|
||||||
|
Registers @racket[encoder] under @racket[type]. The encoder's extensions are
|
||||||
|
used for extension-based selection in @racket[audio-encode].}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#lang scribble/manual
|
||||||
|
|
||||||
|
@(require (for-label racket/base
|
||||||
|
racket/path
|
||||||
|
"../encoder-test.rkt"))
|
||||||
|
|
||||||
|
@title{Encoder Test Program}
|
||||||
|
@author[@author+email["Hans Dijkema" "hans@dijkewijk.nl"]]
|
||||||
|
|
||||||
|
@defmodule[racket-audio/encoder-test]
|
||||||
|
|
||||||
|
The @racketmodname[racket-audio/encoder-test] module is a small integration test
|
||||||
|
and command-line wrapper around @racketmodname[racket-audio/audio-encoder]. It
|
||||||
|
is useful for checking that the native encoder libraries are available and that
|
||||||
|
a concrete source file can be transcoded to Opus or FLAC.
|
||||||
|
|
||||||
|
The module depends on @filepath{tests.rkt} for its default input file. For
|
||||||
|
portable tests, pass an explicit input file.
|
||||||
|
|
||||||
|
@section{Program use}
|
||||||
|
|
||||||
|
Run the test module directly to encode the default test file to a temporary
|
||||||
|
Opus file:
|
||||||
|
|
||||||
|
@verbatim{
|
||||||
|
racket encoder-test.rkt
|
||||||
|
}
|
||||||
|
|
||||||
|
Useful command-line examples:
|
||||||
|
|
||||||
|
@verbatim{
|
||||||
|
racket encoder-test.rkt --encoder opus --input input.flac --output output.opus --bitrate-kbps 224
|
||||||
|
|
||||||
|
racket encoder-test.rkt --encoder flac --input input-96k.flac --output output-48k.flac --sample-rate 48000 --bits-per-sample 24 --compression-level 8
|
||||||
|
}
|
||||||
|
|
||||||
|
The program prints the selected encoder, settings, percentage progress, and a
|
||||||
|
summary of the result hash returned by @racket[audio-encode]. Progress is based
|
||||||
|
on input frames read from the decoder.
|
||||||
|
|
||||||
|
@section{Program options}
|
||||||
|
|
||||||
|
The command-line wrapper accepts these options:
|
||||||
|
|
||||||
|
@itemlist[#:style 'compact
|
||||||
|
@item{@tt{-e}, @tt{--encoder}: @tt{opus} or @tt{flac}.}
|
||||||
|
@item{@tt{-i}, @tt{--input}: input audio file.}
|
||||||
|
@item{@tt{-o}, @tt{--output}: output audio file.}
|
||||||
|
@item{@tt{--sample-rate}: target sample rate or @tt{source}.}
|
||||||
|
@item{@tt{--bits-per-sample}: target FLAC bit depth or @tt{source}.}
|
||||||
|
@item{@tt{--bitrate-kbps}: Opus bitrate in kbit/s.}
|
||||||
|
@item{@tt{--compression-level}: FLAC compression level.}
|
||||||
|
@item{@tt{--no-tags}: disable copying tags and embedded pictures.}]
|
||||||
|
|
||||||
|
@section{Racket functions}
|
||||||
|
|
||||||
|
@defproc[(encoder-test [input-file path-string?]
|
||||||
|
[output-file (or/c path-string? #f)]
|
||||||
|
[encoder (or/c symbol? string?)]
|
||||||
|
[settings hash?]
|
||||||
|
[#:copy-tags? copy-tags? boolean? #t])
|
||||||
|
hash?]{
|
||||||
|
Runs one encode test and prints a human-readable summary. The return value is
|
||||||
|
the result hash produced by @racket[audio-encode]. When @racket[output-file] is
|
||||||
|
@racket[#f], a temporary output path is chosen from the encoder kind.}
|
||||||
|
|
||||||
|
@defproc[(encoder-test-opus [input-file path-string?]
|
||||||
|
[output-file (or/c path-string? #f) #f]
|
||||||
|
[#:bitrate-kbps bitrate-kbps exact-positive-integer? 160]
|
||||||
|
[#:sample-rate sample-rate (or/c exact-positive-integer? 'source) 'source]
|
||||||
|
[#:copy-tags? copy-tags? boolean? #t])
|
||||||
|
hash?]{
|
||||||
|
Encodes @racket[input-file] to an Opus file using @racket[encoder-test]. The
|
||||||
|
bitrate argument is expressed in kbit/s and is converted to the @racket['bitrate]
|
||||||
|
setting used by the Opus backend.
|
||||||
|
|
||||||
|
The @racket[sample-rate] argument is normally @racket['source]. Opus encoding
|
||||||
|
passes the input rate to @tt{libopusenc}; @tt{libopusenc} performs the internal
|
||||||
|
resampling required for Opus output.}
|
||||||
|
|
||||||
|
@defproc[(encoder-test-flac [input-file path-string?]
|
||||||
|
[output-file (or/c path-string? #f) #f]
|
||||||
|
[#:compression-level compression-level exact-nonnegative-integer? 8]
|
||||||
|
[#:sample-rate sample-rate (or/c exact-positive-integer? 'source) 'source]
|
||||||
|
[#:bits-per-sample bits-per-sample (or/c exact-positive-integer? 'source) 'source]
|
||||||
|
[#:copy-tags? copy-tags? boolean? #t])
|
||||||
|
hash?]{
|
||||||
|
Encodes @racket[input-file] to a FLAC file using @racket[encoder-test]. When
|
||||||
|
@racket[sample-rate] or @racket[bits-per-sample] is not @racket['source], the
|
||||||
|
FLAC pipeline requests the corresponding output format from
|
||||||
|
@racketmodname[racket-audio/audio-encoder].}
|
||||||
@@ -21,6 +21,8 @@
|
|||||||
@include-section["audio-player.scrbl"]
|
@include-section["audio-player.scrbl"]
|
||||||
@include-section["audio-sniffer.scrbl"]
|
@include-section["audio-sniffer.scrbl"]
|
||||||
@include-section["taglib.scrbl"]
|
@include-section["taglib.scrbl"]
|
||||||
|
@include-section["audio-encoder.scrbl"]
|
||||||
|
@include-section["encoder-test.scrbl"]
|
||||||
@include-section["play-test.scrbl"]
|
@include-section["play-test.scrbl"]
|
||||||
@include-section["audio-placed-player.scrbl"]
|
@include-section["audio-placed-player.scrbl"]
|
||||||
@include-section["audio-decoder.scrbl"]
|
@include-section["audio-decoder.scrbl"]
|
||||||
|
|||||||
Reference in New Issue
Block a user