WaxLabel is a pure-Go library and command-line tool for reading and writing audio-file metadata: tags, embedded pictures, and chapters where the format supports them. It is preservation-first: edits are planned against the parsed native structure, metadata is rewritten only where needed, and audio bytes are copied rather than transcoded.
The public API lives in github.com/colespringer/waxlabel and
github.com/colespringer/waxlabel/tag. Codec packages are internal implementation
details.
WaxLabel reads and writes FLAC, Ogg Vorbis, Ogg Opus, MP3, WAV, MP4/M4A, raw AAC/ADTS, Matroska/WebM, and AIFF/AIFF-C.
Library:
go get github.com/colespringer/waxlabelCLI:
go install github.com/colespringer/waxlabel/cmd/waxlabel@latestWaxLabel requires Go 1.26 or newer. The library packages use only the standard library; the CLI uses Cobra.
package main
import (
"context"
"fmt"
"log"
waxlabel "github.com/colespringer/waxlabel"
"github.com/colespringer/waxlabel/tag"
)
func main() {
ctx := context.Background()
doc, err := waxlabel.ParseFile(ctx, "track.flac")
if err != nil {
log.Fatal(err)
}
fmt.Println(doc.Fields().Title)
plan, err := doc.Edit().
Set(tag.Title, "New Title").
Set(tag.Artist, "Lead", "Featured").
Clear(tag.Encoder).
Prepare()
if err != nil {
log.Fatal(err)
}
fmt.Println(plan)
_, result, err := plan.Execute(ctx, waxlabel.SaveBack())
if err != nil {
log.Fatal(err)
}
fmt.Println("committed:", result.Committed)
}Parse, ParseFile, and OpenSource return an immutable Document. A document
does not hold an open file descriptor, and accessors return detached data. Editing
starts with Document.Edit(), resolves through Editor.Prepare(), and writes only
when the resulting Plan is executed.
Write destinations:
SaveBack()atomically rewrites the parsed file in place. A no-op writes nothing.SaveAsFile(path)writes a complete new file.WriteTo(w, source)streams a complete output to anio.Writer.
waxlabel dump track.flac
waxlabel plan track.flac --set TITLE="New Title"
waxlabel set track.flac --set TITLE="New Title" --add ARTIST=Featured
waxlabel lint track.flac --fix
waxlabel verify track.flac
waxlabel caps --format flac
waxlabel keys
waxlabel copy source.flac dest.m4a
waxlabel diff before.flac after.flacCommands:
| Command | Purpose |
|---|---|
dump <file>... |
Show tags, audio properties, pictures, chapters, and parse warnings. --native also shows native blocks and source families. |
plan <file>... |
Preview an edit without writing. |
set <file>... |
Apply edits and save. Use -o for a new output file. |
lint <file>... |
Report metadata issues. --fix applies only safe fixes, such as clearing encoder noise or stripping legacy containers. |
verify <file>... |
Print tag-independent audio-essence digests. --whole-file also hashes every byte. |
caps <file>... or caps --format <name> |
Show what a file or format can store and edit. |
keys |
List the canonical tag vocabulary and cardinality. |
copy <source> <dest> |
Overlay source metadata onto the destination, reporting values that carry, downgrade, or drop. |
diff <a> <b> |
Compare canonical tags, pictures, and chapters. |
Common edit flags:
--set KEY=VALUEreplaces a value.--add KEY=VALUEappends a value.--clear KEYremoves a key.--strip-encoderremoves inherited encoder stamps where the format allows it.--add-cover FILEand--add-picture ROLE=FILEembed pictures.--remove-picture SELECTORand--remove-picturesremove pictures.--add-chapter TIMESTAMP=TITLEand--clear-chaptersedit chapters. A chapterTIMESTAMPis[H:]MM:SS[.fff](for example1:02:03.500or02:03) or a bare number of seconds (123or123.5); when present, the seconds field's fractional part is 1 to 3 digits (millisecond resolution).--preset preserve|compatible|minimal,--legacy preserve|strip,--padding N, and--no-paddingshape the write.--numeric-genrewrites a recognized genre as its numeric reference where the format supports one (ID3'sTCON). It converts only on a genuine genre change; when the canonical genre is unchanged it is a no-op (an existing numeric or text genre is left as it is, not rewritten).
The read commands accept a single - for standard input; set - also works when
paired with -o. dump, verify, lint, plan, and set can walk directories
with --recursive. A file's format is detected from its leading bytes, not from
its extension: extensions only filter which files a --recursive walk visits.
A walk skips hidden directories (those whose name begins with .) unless one is
named as the root. A direct file argument whose bytes match no supported
container is unsupported (exit 3), regardless of extension.
-o writes atomically (a temp file in the target's directory, then a rename), so it
must name a regular file in a writable directory. It is not a discard sink, and
-o /dev/null fails. To write nothing, omit -o or use plan to preview the edit.
All data commands accept --json. Commands that process many inputs return an
array, one element per input. Single-result commands such as diff, copy,
caps --format, keys, and version return one object.
In dump and caps JSON, the top-level format is the codec family, such as
Matroska or AIFF. subformat is the exact container subtype, such as WebM
or AIFC. For plain formats the two values are the same; in dump, subformat
matches properties.container.
The warnings array reported by set and plan describes the write plan: what
the write will change, downgrade, or drop. It does not include post-write
cleanliness findings such as an inherited encoder stamp or a malformed value. Run
lint on the saved file to check those.
In set, plan, and lint --fix JSON, changes and operations have
different meanings. changes is the canonical tag-level diff: which keys are
added, removed, or replaced. operations is the structural write list, such as
an ID3v2 frame rewrite, an encoder-stamp strip, or a chapter-track rewrite. Some
fixes touch only native structure, so they can report an empty changes list
with non-empty operations. Empty changes does not mean nothing was written.
Exit code summary:
0: success, clean lint, or identical diff.1: generic error, lint warnings found, or files differ.2: usage error, such as a bad--formatflag value, giving the same key to both--set/--addand--clear(they conflict), or a bare directory argument without--recursive.3: an input file whose bytes match no supported container signature (unsupported regardless of its extension), or an unsupported metadata operation.4: invalid or contradictory data, including a recognized container whose contents are corrupt.5: source changed since parse.6: I/O or not-found error.130: canceled or timed out.
For multi-file commands, a more severe file error determines the process exit code.
Canonical tags. WaxLabel projects native metadata into a format-neutral
tag.TagSet. Known keys live in tag.KnownKeys(), but unknown uppercase keys are
preserved as custom fields when a format can carry them.
Presence-aware edits. WaxLabel distinguishes absent keys, present empty-string values, and keys present with no values. Formats cannot store every distinction, so the plan reports what will actually be written after codec projection.
Planning before writing. Prepare() builds a Plan and a WriteReport from
the same state used by Execute(). The preview is not a separate guess.
Preservation. Legacy or secondary metadata containers are preserved and warned
by default. Use WithLegacyPolicy(LegacyStrip) or the CLI's --legacy strip when
you want them removed.
Terminal-safe text. Human text renderers sanitize untrusted tag values, paths, and warning strings so control bytes cannot inject terminal escapes. JSON output uses the exact machine-readable values.
Audio identity. Document.HashAudioEssence hashes encoded audio plus
decoder-critical configuration, independent of tags. Document.HashFile hashes the
entire file.
| Format | Metadata | Notes |
|---|---|---|
| FLAC | read/write | Vorbis comments and FLAC pictures; padding is fully controllable. |
| Ogg Vorbis / Opus | read/write | Vorbis comments and METADATA_BLOCK_PICTURE; audio packet payloads are preserved. |
| MP3 | read/write | ID3v2 is writable (a new tag is written as ID3v2.3); ID3v1 and APEv2 are surfaced as legacy families. |
| WAV | read/write | RIFF LIST/INFO plus embedded id3 ; chunks are preserved. |
| MP4 / M4A / M4B | read/write | iTunes ilst, cover art, Nero chapters, and QuickTime chapter text tracks. Fragmented MP4 is rejected. |
| Matroska / WebM | read/write | Scoped SimpleTags, segment title, attachments, and default-edition chapters. WebM cannot write cover attachments. |
| AAC (ADTS) | read/write | Front ID3v2 tag (a new tag is written as ID3v2.4) plus ADTS frames. |
| AIFF / AIFF-C | read/write | Native text chunks plus embedded ID3 ; chunks are preserved. |
The capability table below is generated from the same codec capability model used
by waxlabel caps.
| Format | Pictures | Chapters |
|---|---|---|
| AAC (ADTS) | read full, write full · APIC frame | read none, write none |
| AIFF | read full, write full · APIC (ID3 chunk) | read none, write none |
| FLAC | read full, write full · FLAC PICTURE block | read none, write none · CUESHEET preserved |
| MP3 | read full, write full · APIC frame | read none, write none · CHAP preserved |
| MP4 | read full, write full · covr atom (JPEG/PNG/BMP) | read full, write full · Nero chpl and a QuickTime chapter text track |
| Matroska | read full, write full · AttachedFile (image attachment) | read full, write full · Chapters > EditionEntry > ChapterAtom (default edition) |
| Ogg Opus | read full, write full · METADATA_BLOCK_PICTURE | read none, write none |
| Ogg Vorbis | read full, write full · METADATA_BLOCK_PICTURE | read none, write none |
| WAV | read full, write full · APIC (id3 chunk) | read none, write none · cue/adtl preserved |
Known issue: Matroska reproducibility. Matroska expects FileUID and ChapterUID values to be random. WaxLabel follows that rule for new attachments and chapters, so a write that creates or rebuilds those IDs will not be byte-identical across runs. The audio bytes are still preserved. A tag-only rewrite that mints no new ID can remain deterministic, and a future option could derive IDs from a stable seed or content hash.
These limits are intentional for now; each is bounded and does not affect the common path:
- Matroska essence digest, interleaved metadata. The audio-essence digest hashes a single contiguous cluster span. If a muxer places a non-cluster element such as Cues or Tags between clusters, that element is included in the digest. Re-rendering it could change the digest even when the audio bytes are unchanged. The multi-range essence model already exists (used by MP4 and Ogg); closing this means populating it from the Matroska cluster runs and bumping the digest version.
- Malformed ID3v2.2
PICwith no description terminator. A non-conformant embedded picture whose description is missing its terminating NUL is parsed with an empty description and the remaining bytes as image data. That reading is persisted on rewrite, so the original malformed bytes do not round-trip. Conformant pictures are unaffected. - Present-but-valueless fields collapse to absent. A field present in the source
with no value reads back as absent rather than present-empty; this is consistent
across formats and matches how
--clearand a set-empty value are distinguished elsewhere.
Input is treated as untrusted. Parsers use bounded allocation and recursion limits, fuzz tests cover arbitrary input, and human output sanitizes terminal-control bytes.
Save-back writes use a temp file in the target directory, fsync it, rename it into
place, and fsync the directory. If the source file changed since parse,
SaveBack() refuses with waxerr.ErrSourceChanged instead of overwriting newer
bytes.
Atomic rename saves have normal filesystem consequences:
- Editing through a symlink rewrites the symlink target and leaves the link in place.
- Other hard links keep pointing at the old inode.
- A read-only file can still be replaced when its directory is writable; the original mode is preserved on the replacement.
MIT.
Mutagen, TagLib, bogem/id3v2, sentriz/go-taglib, and libogg were direct influences on WaxLabel's design and test cross-checks. WaxLabel's implementation follows public specifications and does not copy their code.