diff --git a/README.md b/README.md index c1e7714..462d521 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,79 @@ # 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 -% brew install flac -% brew install mpg123 -% brew install ffmpeg-full +For playback and decoding, install the native libraries used by the selected +backends: +- 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 +``` diff --git a/audio-encoder.rkt b/audio-encoder.rkt index b0a6a8a..4cfd875 100644 --- a/audio-encoder.rkt +++ b/audio-encoder.rkt @@ -68,52 +68,148 @@ (for-each (lambda (k) (hash-set! out k (hash-ref b k))) (hash-keys b))) 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) (with-handlers ([exn:fail? (lambda (e) (warn-sound "Could not copy tags from ~a to ~a: ~a" input-file output-file (exn-message e)) - #f)]) + (make-tag-result 'taglib-post-copy #f #f (exn-message e)))]) (call-with-id3-tags input-file (lambda (src) (call-with-id3-tags output-file (lambda (dst) - (when (and (tags-valid? src) (tags-valid? dst)) - (tag-value-copy! src dst tags-title tags-title! empty-string?) - (tag-value-copy! src dst tags-album tags-album! empty-string?) - (tag-value-copy! src dst tags-artist tags-artist! empty-string?) - (tag-value-copy! src dst tags-comment tags-comment! empty-string?) - (tag-value-copy! src dst tags-genre tags-genre! empty-string?) - (tag-value-copy! src dst tags-composer tags-composer! empty-string?) - (tag-value-copy! src dst tags-album-artist tags-album-artist! empty-string?) - (tag-value-copy! src dst tags-year tags-year! empty-number?) - (tag-value-copy! src dst tags-track tags-track! empty-number?) - (tag-value-copy! src dst tags-disc-number tags-disc-number! empty-number?) - (let ((picture (tags-picture src))) - (unless (eq? picture #f) (tags-picture! dst picture))) - (tags-save! dst))) + (if (and (tags-valid? src) (tags-valid? dst)) + (begin + (tag-value-copy! src dst tags-title tags-title! empty-string?) + (tag-value-copy! src dst tags-album tags-album! empty-string?) + (tag-value-copy! src dst tags-artist tags-artist! empty-string?) + (tag-value-copy! src dst tags-comment tags-comment! empty-string?) + (tag-value-copy! src dst tags-genre tags-genre! empty-string?) + (tag-value-copy! src dst tags-composer tags-composer! empty-string?) + (tag-value-copy! src dst tags-album-artist tags-album-artist! empty-string?) + (tag-value-copy! src dst tags-year tags-year! empty-number?) + (tag-value-copy! src dst tags-track tags-track! empty-number?) + (tag-value-copy! src dst tags-disc-number tags-disc-number! empty-number?) + (let ((picture (tags-picture src))) + (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) - #t)) + #:mode 'read))) + + (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 #: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 effective-settings (if (and copy-tags? (eq? kind 'opus)) + (source-tags->opus-settings input-file settings) + settings)) (define backend-handle #f) (define format #f) (define output-format #f) (define converter #f) (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) (when (eq? backend-handle #f) ;; Record the resolved output format, not merely the incoming PCM format. ;; This matters when only FLAC bit depth changes, because no swresample ;; converter is needed but the resulting FLAC stream metadata still differs. - (set! output-format ((audio-encoder-settings encoder) settings fmt)) - (set! backend-handle ((audio-encoder-open encoder) output-file settings fmt)))) + (set! output-format ((audio-encoder-settings encoder) effective-settings fmt)) + (set! backend-handle ((audio-encoder-open encoder) output-file effective-settings fmt)))) (define (write-backend! fmt buffer buf-len) (ensure-open! fmt) @@ -125,8 +221,8 @@ ;; converter by default: libopusenc accepts the source input rate and has ;; its own resampler, and opus-encoder.rkt feeds it float PCM directly. (when (and (eq? kind 'flac) (eq? converter #f)) - (when (pcm-conversion-needed? input-format settings) - (set! converter (make-pcm-converter input-format settings))))) + (when (pcm-conversion-needed? input-format effective-settings) + (set! converter (make-pcm-converter input-format effective-settings))))) (define (write-converted! input-format buffer buf-len) (ensure-converter! input-format) @@ -148,12 +244,15 @@ ;; Keep stream metadata, but delay encoder creation until the first audio ;; buffer. Some decoders report an output-oriented stream format first ;; 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) (let ((effective-format (merge-hash format buf-info))) (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)) (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 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))) (hash-set! r 'encoder kind) (hash-set! r 'input input-file) (hash-set! r 'output output-file) (hash-set! r 'input-format 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 'tag-copy tags-result) r)) ) ; end of module diff --git a/encoder-test.rkt b/encoder-test.rkt index cababbd..bc3ec01 100644 --- a/encoder-test.rkt +++ b/encoder-test.rkt @@ -58,7 +58,18 @@ (hash-ref fmt 'sample-rate "?") (hash-ref fmt 'channels "?") (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")) (define (display-result result) @@ -68,11 +79,26 @@ (displayln (format "encoder : ~a" (hash-ref result 'encoder '?))) (displayln (format "input : ~a" (hash-ref result 'input '?))) (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 "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 "tag copy : ~a" (tag-summary (hash-ref result 'tag-copy #f)))) 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]) (let* ((enc (encoder-symbol encoder)) (out (if output-file output-file (default-output-file enc)))) @@ -81,7 +107,10 @@ (displayln (format " -> ~a" out)) (displayln (format "encoder : ~a" enc)) (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] [output-file #f] diff --git a/opus-encoder.rkt b/opus-encoder.rkt index bc8ff32..8073ec7 100644 --- a/opus-encoder.rkt +++ b/opus-encoder.rkt @@ -1,7 +1,9 @@ (module opus-encoder racket/base (require ffi/unsafe - "private/utils.rkt") + racket/string + "private/utils.rkt" + "taglib.rkt") (provide opus-encoder-available? opus-encoder-default-settings @@ -156,6 +158,54 @@ (check-ope 'opus-comment (ope_comments_add comments (string-upcase (symbol->string k)) v))))) (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) (unless (opus-encoder-available?) (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)) (comments (ope_comments_create))) (add-comments! comments resolved) + (add-picture! comments resolved) (let-values (((enc err) (ope_encoder_create_file file comments (hash-ref resolved 'sample-rate) (hash-ref resolved 'channels) diff --git a/scrbl/audio-encoder.scrbl b/scrbl/audio-encoder.scrbl new file mode 100644 index 0000000..aa70cb9 --- /dev/null +++ b/scrbl/audio-encoder.scrbl @@ -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].} diff --git a/scrbl/encoder-test.scrbl b/scrbl/encoder-test.scrbl new file mode 100644 index 0000000..e287da4 --- /dev/null +++ b/scrbl/encoder-test.scrbl @@ -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].} diff --git a/scrbl/racket-audio.scrbl b/scrbl/racket-audio.scrbl index 7e13ed0..465a386 100644 --- a/scrbl/racket-audio.scrbl +++ b/scrbl/racket-audio.scrbl @@ -21,6 +21,8 @@ @include-section["audio-player.scrbl"] @include-section["audio-sniffer.scrbl"] @include-section["taglib.scrbl"] +@include-section["audio-encoder.scrbl"] +@include-section["encoder-test.scrbl"] @include-section["play-test.scrbl"] @include-section["audio-placed-player.scrbl"] @include-section["audio-decoder.scrbl"]