diff --git a/README.md b/README.md index 462d521..535e1e5 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ For encoding, also install: The Opus encoder backend uses libopusenc directly. The FLAC encoder backend uses libFLAC directly. FLAC sample-rate conversion uses the existing FFmpeg -swresample layer. +swresample layer. Metadata and cover-art copying use the TagLib wrapper; the +public `taglib.rkt` API also supports read-write tag editing. ## macOS diff --git a/scrbl/audio-encoder.scrbl b/scrbl/audio-encoder.scrbl index aa70cb9..db93abc 100644 --- a/scrbl/audio-encoder.scrbl +++ b/scrbl/audio-encoder.scrbl @@ -62,8 +62,8 @@ 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. +metadata is copied after the encoded file has been written, using the +read-write API from @racketmodname[racket-audio/taglib]. 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 diff --git a/scrbl/taglib.scrbl b/scrbl/taglib.scrbl index ee8f6d3..b35d37a 100644 --- a/scrbl/taglib.scrbl +++ b/scrbl/taglib.scrbl @@ -9,48 +9,72 @@ @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. +API used by the audio package. It wraps the lower level TagLib C FFI module and +presents a Racket API for common tags, generic properties, audio 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 module can be used in two modes. The default mode is read-only and returns +a snapshot of the metadata. A handle opened with @racket[#:mode 'read-write] +keeps the native TagLib file open and can be modified with the setter +procedures documented below. Changes are written to the media file by calling +@racket[tags-save!]. -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. +The name @racket[id3-tags] is historical. The implementation uses TagLib, so +the usable file types are the file types supported by the TagLib library +available at run time. -@section{Reading metadata} +@section{Opening and closing tag handles} -@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. +@defproc[(id3-tags [file path-string?] + [#:mode mode (or/c 'read 'read-only 'read-write 'write) 'read]) + any/c]{ +Opens @racket[file] through TagLib and returns an opaque tag handle. In the +default read-only mode, the module copies the values needed on the Racket side, +frees the native TagLib objects, and returns a snapshot handle. -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].} +In read-write mode, the native TagLib file remains open. Setter procedures may +then be used to modify fields, properties, and pictures. Call +@racket[tags-save!] to write changes and @racket[tags-close!] to close the +native handle. -@defproc[(tags-valid? [tags any/c]) boolean?]{ -Returns @racket[#t] when @racket[id3-tags] successfully opened the file and -TagLib reported it as valid.} +On Windows, the implementation retries with the wide-character TagLib open +function when the normal open function does not produce a valid TagLib file.} + +@defproc[(call-with-id3-tags [file path-string?] + [proc procedure?] + [#:mode mode (or/c 'read 'read-only 'read-write 'write) 'read]) + any/c]{ +Opens @racket[file], calls @racket[proc] with the tag handle, and closes the +handle afterwards with @racket[tags-close!]. This is most useful for +read-write code because it avoids leaking the native TagLib file handle.} + +@deftogether[ +(@defproc[(tags-valid? [tags any/c]) boolean?] + @defproc[(tags-read-write? [tags any/c]) boolean?] + @defproc[(tags-closed? [tags any/c]) boolean?])]{ +Return handle state. @racket[tags-valid?] reports whether TagLib opened the +file successfully. @racket[tags-read-write?] reports whether the handle was +opened in read-write mode. @racket[tags-closed?] reports whether the native +TagLib file handle has been closed.} + +@deftogether[ +(@defproc[(tags-save! [tags any/c]) boolean?] + @defproc[(tags-close! [tags any/c]) void?])]{ +@racket[tags-save!] writes pending changes for a read-write handle to the media +file. @racket[tags-close!] closes the native TagLib file handle. Closing a +read-only snapshot is harmless. +} @racketblock[ -(define tags (id3-tags "song.mp3")) - -(when (tags-valid? tags) - (printf "~a - ~a\n" (tags-artist tags) (tags-title tags)))] +(call-with-id3-tags "track.flac" + (lambda (tags) + (when (tags-valid? tags) + (tags-title! tags "New title") + (tags-save! tags))) + #:mode 'read-write)] @section{Common tag fields} @@ -59,32 +83,52 @@ TagLib reported it as valid.} @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?])] + @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. +are returned as the empty string.} @deftogether[ (@defproc[(tags-year [tags any/c]) integer?] - @defproc[(tags-track [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]. +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]. +(@defproc[(tags-title! [tags any/c] [value (or/c string? 'clear)]) void?] + @defproc[(tags-album! [tags any/c] [value (or/c string? 'clear)]) void?] + @defproc[(tags-artist! [tags any/c] [value (or/c string? 'clear)]) void?] + @defproc[(tags-comment! [tags any/c] [value (or/c string? 'clear)]) void?] + @defproc[(tags-genre! [tags any/c] [value (or/c string? 'clear)]) void?])]{ +Set common textual fields on a read-write handle. Passing @racket['clear] +clears the field. Call @racket[tags-save!] to persist the change.} -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. +@deftogether[ +(@defproc[(tags-year! [tags any/c] [value (or/c exact-nonnegative-integer? 'clear)]) void?] + @defproc[(tags-track! [tags any/c] [value (or/c exact-nonnegative-integer? 'clear)]) void?])]{ +Set numeric common fields on a read-write handle. Passing @racket['clear] +writes zero through the TagLib C API and updates the Racket-side cache to +@racket[-1].} + +@section{Selected generic fields} + +@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 @racket['composer] key, the album artist from +@racket['albumartist], and the disc number from @racket['discnumber]. Use +@racket[tags-keys] and @racket[tags-ref] for direct access to the complete +generic property store.} + +@deftogether[ +(@defproc[(tags-composer! [tags any/c] [value (or/c string? 'clear)]) void?] + @defproc[(tags-album-artist! [tags any/c] [value (or/c string? 'clear)]) void?] + @defproc[(tags-disc-number! [tags any/c] + [value (or/c exact-nonnegative-integer? string? 'clear)]) + void?])]{ +Set selected generic properties on a read-write handle. The disc number may be +provided as a number or as the exact string that should be written.} @section{Audio properties} @@ -92,10 +136,11 @@ parsed as a number, the result may be @racket[#f]. Use @racket[tags-keys] and (@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?])] + @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]. +Hz, bit rate in kbit/s, and number of channels. These values are read-only +properties of the media stream, not editable tags. Missing values are returned +as @racket[-1].} @section{Generic properties} @@ -109,9 +154,39 @@ 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].} +@defproc[(tags-set! [tags any/c] + [key (or/c symbol? string?)] + [value (or/c string? 'clear)]) + void?]{ +Sets a generic property on a read-write handle. Symbol keys are converted to +upper-case TagLib property names; string keys are passed as supplied. Passing +@racket['clear] clears the property.} + +@defproc[(tags-set-values! [tags any/c] + [key (or/c symbol? string?)] + [values (or/c (listof string?) 'clear)]) + void?]{ +Replaces a generic property with zero or more values. Passing @racket['clear] +removes the property.} + +@defproc[(tags-append! [tags any/c] + [key (or/c symbol? string?)] + [value string?]) + void?]{ +Appends a value to a generic property on a read-write handle.} + +@defproc[(tags-clear! [tags any/c] + [key (or/c symbol? string?)]) + void?]{ +Clears a generic property on a read-write handle.} + @racketblock[ -(for ([key (in-list (tags-keys tags))]) - (printf "~a: ~s\n" key (tags-ref tags key)))] +(call-with-id3-tags "track.flac" + (lambda (tags) + (tags-set-values! tags 'composer '("Johann Sebastian Bach")) + (tags-set! tags 'discnumber "1") + (tags-save! tags)) + #:mode 'read-write)] Generic properties may contain multiple values for a single key. The API keeps those values as lists instead of joining them into one string. @@ -120,8 +195,8 @@ those values as lists instead of joining them into one string. 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]. +the picture procedures documented below. It can also be written to another +file with @racket[tags-picture!] or @racket[tags-append-picture!]. @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 @@ -130,14 +205,15 @@ 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->description [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)])] + @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. +recognized.} @defproc[(tags-picture->bitmap [tags any/c]) (or/c (is-a?/c bitmap%) #f)]{ @@ -150,41 +226,85 @@ Reads the embedded picture bytes with @racket[read-bitmap] and returns a 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.} +@racket[#f] when the tag handle has no picture.} + +@defproc[(make-tags-picture [mimetype string?] + [kind integer?] + [data (or/c bytes? (is-a?/c bitmap%))] + [#:description description string? ""]) + id3-picture?]{ +Creates a picture value from encoded image bytes or from a @racket[bitmap%]. +The MIME type should normally be @racket["image/jpeg"] or @racket["image/png"].} + +@defproc[(make-tags-picture-from-bitmap [bitmap (is-a?/c bitmap%)] + [kind integer?] + [#:mimetype mimetype string? "image/png"] + [#:description description string? ""]) + id3-picture?]{ +Creates a picture value by encoding @racket[bitmap] as PNG or JPEG.} + +@deftogether[ +(@defproc[(tags-picture! [tags any/c] + [picture (or/c id3-picture? 'clear)]) + void?] + @defproc[(tags-append-picture! [tags any/c] + [picture id3-picture?]) + void?] + @defproc[(tags-clear-picture! [tags any/c]) void?])]{ +Set, append, or clear embedded artwork on a read-write handle. The procedures +use TagLib complex properties underneath. Call @racket[tags-save!] to persist +the change.} @racketblock[ -(define ext (tags-picture->ext tags)) +(define cover + (make-tags-picture "image/jpeg" 3 (file->bytes "cover.jpg") + #:description "Cover")) -(when ext - (tags-picture->file tags - (format "cover.~a" ext)))] +(call-with-id3-tags "track.flac" + (lambda (tags) + (tags-picture! tags cover) + (tags-save! tags)) + #:mode 'read-write)] @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. +(@defproc[(id3-picture? [v any/c]) boolean?] + @defproc[(id3-picture-mimetype [picture id3-picture?]) string?] + @defproc[(id3-picture-kind [picture id3-picture?]) integer?] + @defproc[(id3-picture-size [picture id3-picture?]) integer?] + @defproc[(id3-picture-bytes [picture id3-picture?]) bytes?] + @defproc[(id3-picture-description [picture id3-picture?]) string?])]{ +Access the fields of a picture value. These procedures are useful when the +caller wants to process the image bytes directly or pass a picture to another +component.} @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 contains the keys @racket['valid?], @racket['read-write?], +@racket['closed?], @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{Copying tags and pictures} + +The encoder pipeline uses this module for metadata transfer. For FLAC output, +@racket[audio-encode] first writes the audio stream and then opens the resulting +file with @racket[id3-tags] in read-write mode to copy tags and pictures through +TagLib. For Opus output, comments and pictures are supplied to +@tt{libopusenc} before encoding starts, because OpusTags are written at the +start of the Ogg Opus stream. + +Applications that need explicit metadata editing should use the read-write API +directly, as in the examples above. + @section{Example} @racketblock[ @@ -209,10 +329,10 @@ 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. +A read-only tag handle is a Racket-side snapshot. A read-write tag handle keeps +the native TagLib file open until @racket[tags-close!] is called. Setter +procedures update both the native file and the Racket-side cache; the changes +are persisted only after @racket[tags-save!] succeeds. The implementation normalizes generic property names by lower-casing TagLib property keys and converting them to symbols. Values remain lists of strings