diff --git a/.planning/phases/19-wav-tag-writer/19-RESEARCH.md b/.planning/phases/19-wav-tag-writer/19-RESEARCH.md new file mode 100644 index 0000000..a4fff9d --- /dev/null +++ b/.planning/phases/19-wav-tag-writer/19-RESEARCH.md @@ -0,0 +1,470 @@ +# Phase 19: WAV Tag Writer - Research + +**Researched:** 2026-03-18 +**Domain:** WAV RIFF container manipulation with ID3v2 tag embedding +**Confidence:** HIGH + +## Summary + +WAV files use the RIFF container format. Metadata is embedded as an ID3v2 tag inside a RIFF chunk with ID `id3 ` (lowercase, trailing space). The critical finding is that **neither `bogem/id3v2` nor `dhowden/tag` supports WAV/RIFF natively** — `bogem/id3v2` only handles raw ID3v2 streams (it looks for "ID3" magic bytes at position 0), and `dhowden/tag` only detects "fLaC", "OggS", "ftyp", "ID3", and "DSD " magic bytes but has no RIFF case. This means we need a **custom RIFF chunk parser/writer** for the container level, while still using `bogem/id3v2` for the ID3v2 tag payload itself. + +The RIFF structure is simple: a sequence of 8-byte-header chunks (4-byte ID + 4-byte little-endian size) with optional padding bytes for odd-length alignment. The implementation approach is: read all RIFF chunks, preserve them byte-for-byte, extract existing ID3v2 data from the `id3 ` chunk (if present), build/modify the ID3v2 tag using `bogem/id3v2`, then write a new RIFF file with all original chunks plus the updated `id3 ` chunk appended at end. All wrapped in `fileutil.AtomicWrite` for crash safety. + +For test read-back, since `dhowden/tag` cannot read WAV files, tests must use a custom RIFF parser to extract the `id3 ` chunk data and then pass it to either `bogem/id3v2.ParseReader()` or `tag.ReadID3v2Tags()` via a `bytes.Reader`. + +**Primary recommendation:** Build a ~100-line custom RIFF chunk reader/writer in `wav.go`. Use `bogem/id3v2.NewEmptyTag()` + `ParseReader()` for ID3v2 manipulation, `tag.WriteTo()` for serialization. No new dependencies needed. + + +## User Constraints (from CONTEXT.md) + +### Locked Decisions +- Preserve ALL non-ID3v2 chunks byte-for-byte when rewriting the WAV file (fmt, data, LIST INFO, bext, cue, smpl, iXML, and any unknown/proprietary chunks) +- Existing RIFF LIST INFO chunks are kept as-is, even if they contain stale metadata after an ID3v2 edit — our reader already prefers ID3v2, so stale INFO won't affect display +- Existing ID3v2 chunks in the WAV are replaced entirely — open with bogem/id3v2, apply changes, write fresh tag (same pattern as MP3 writer) +- ID3v2 chunk placed at end of file (after all other chunks) — most common convention, simplest implementation +- No size limit on embedded cover art — accept whatever the user provides, consistent with MP3/FLAC behavior +- JPEG and PNG only, detected by magic bytes via existing `detectMIME()` function — same as MP3 +- Clearing cover art removes the APIC frame only; the ID3v2 chunk is kept even if only text frames remain +- Read and merge existing ID3v2 tags from WAV before applying changes — preserves unknown frames (lyrics, custom tags) added by other tools +- User-facing errors are friendly: "Could not write tags to [filename]: file appears to be damaged" — hide technical details +- Technical error details (chunk offsets, sizes, parse failures) logged via slog for debugging +- Permission-aware messages: distinguish "file is read-only", "file is in use", and generic "write failed" +- Batch error handling identical to MP3/FLAC — use existing BatchFailure struct, no WAV-specific categorization +- Reject RF64/BW64 files (magic bytes 'RF64' instead of 'RIFF') with clear error: "RF64 files are not yet supported" +- Accept BWF (Broadcast Wave) — it's standard RIFF with a bext chunk, which we preserve +- Accept multi-channel WAV — standard RIFF with WAVEFORMATEXTENSIBLE in fmt chunk, which we preserve +- Lenient read, strict write: accept minor spec violations on input (missing padding bytes, incorrect RIFF size), write spec-compliant output (correct padding, correct sizes) +- Warn (slog) above 500MB file size, same as FLAC writer threshold — proceed anyway +- Reject writes that would push output past 4GB (RIFF 32-bit size limit) with clear error: "File too large for WAV format (>4GB). No changes were made." Atomic write ensures original is untouched. + +### Claude's Discretion +- RIFF parser implementation approach (custom vs. library) +- Chunk ordering for non-ID3v2 chunks (preserve original order or normalize) +- Exact padding byte handling for odd-length chunks +- Test fixture file construction approach +- Whether to use `album_artist` field mapping via TPE2 (match MP3 writer) or TPE1 variant + +### Deferred Ideas (OUT OF SCOPE) +- RF64/BW64 support — future phase (FMT-02 in REQUIREMENTS.md) +- RIFF INFO dual-write alongside ID3v2 — explicitly deferred (FMT-03 in REQUIREMENTS.md) +- BWF bext chunk writing — out of scope (preserve only, not write) + + + +## Phase Requirements + +| ID | Description | Research Support | +|----|-------------|-----------------| +| WAV-01 | User can edit all 8 text metadata fields on WAV files via ID3v2 chunk in RIFF container | Custom RIFF parser + `bogem/id3v2` for ID3v2 tag building; reuse `applyTextChanges()` from MP3 writer plus add TPE2 mapping for album_artist | +| WAV-02 | WAV tag writes preserve existing RIFF INFO and other chunks unchanged | RIFF chunk reader preserves all non-`id3 ` chunks byte-for-byte in original order; write them first, then append `id3 ` chunk at end | +| WAV-03 | WAV tag writes preserve audio data identically (lossless round-trip) | All chunks including `data` are copied byte-for-byte; only the `id3 ` chunk is replaced. Verified by comparing `data` chunk bytes before/after | +| WAV-04 | User can embed, replace, and remove cover art via ID3v2 APIC frame | Reuse `applyCoverArtChanges()` from MP3 writer — same APIC frame manipulation on the ID3v2 tag | +| WAV-05 | WAV tag writing uses crash-safe atomic writes | Wrap entire write operation in `fileutil.AtomicWrite()` — same pattern as MP3 and FLAC writers | +| WAV-06 | WAV writer round-trip tests verify all fields via read-back | Custom test helper extracts `id3 ` chunk from RIFF, passes to `tag.ReadID3v2Tags()` for verification; alternatively use `bogem/id3v2.ParseReader()` | + + +## Standard Stack + +### Core +| Library | Version | Purpose | Why Standard | +|---------|---------|---------|--------------| +| `github.com/bogem/id3v2/v2` | v2.1.4 | Build and serialize ID3v2 tags (frames, APIC, text fields) | Already used by MP3 writer; `NewEmptyTag()`, `ParseReader()`, `WriteTo()` provide full ID3v2 lifecycle | +| `encoding/binary` | stdlib | Read/write little-endian RIFF chunk headers (4-byte sizes) | RIFF is little-endian; stdlib covers all needs | +| `yellowjacket/backend/fileutil` | internal | `AtomicWrite()` for crash-safe write-to-temp-then-rename | Project standard; used by MP3 and FLAC writers | + +### Supporting +| Library | Version | Purpose | When to Use | +|---------|---------|---------|-------------| +| `github.com/dhowden/tag` | v0.0.0-20240417 | Read-back verification in tests via `ReadID3v2Tags()` | Test assertion: extract `id3 ` chunk bytes, wrap in `bytes.Reader`, call `tag.ReadID3v2Tags()` | +| `io`, `os`, `bytes` | stdlib | File I/O, stream copying, buffer management | Throughout RIFF parsing and writing | + +### Alternatives Considered +| Instead of | Could Use | Tradeoff | +|------------|-----------|----------| +| Custom RIFF parser (~100 lines) | `golang.org/x/image/riff` | Read-only (no writer); also read-only streaming API not suited to extracting/replacing individual chunks. Custom is better. | +| Custom RIFF parser | Third-party WAV library (e.g. `go-audio/wav`) | Adds dependency; most WAV libs focus on audio samples not metadata chunks; none handle ID3v2-in-RIFF. Custom is cleaner. | +| `tag.ReadID3v2Tags()` in tests | `bogem/id3v2.ParseReader()` in tests | Both work; `dhowden/tag` matches the project's existing `metadata.ExtractTags` verifier pattern but requires a RIFF extraction wrapper. Use whichever is cleaner. | + +## Architecture Patterns + +### Recommended Project Structure +``` +backend/tagwriter/ +├── tagwriter.go # FormatWAV constant + DetectFormat ".wav" case (modify) +├── pipeline.go # case FormatWAV: writeWavTags() dispatch (modify) +├── mp3.go # Existing — applyTextChanges(), applyCoverArtChanges() reused +├── flac.go # Existing +├── wav.go # NEW — writeWavTags(), RIFF chunk reader/writer, applyTextChangesWav() +├── wav_test.go # NEW — createTestWAV(), round-trip tests +└── helpers_test.go # Existing — tinyJPEG(), testLogger() shared +``` + +### Pattern 1: RIFF Chunk Reader +**What:** Parse a WAV file into a slice of `riffChunk{id [4]byte, data []byte}` structs, preserving every chunk in order. +**When to use:** At the start of `writeWavTags()` to read the existing file. +**Example:** +```go +type riffChunk struct { + id [4]byte + data []byte +} + +// parseRIFF reads a RIFF WAVE file and returns all sub-chunks. +// Returns an error if the file is RF64 or not a valid RIFF WAVE. +func parseRIFF(r io.ReadSeeker) ([]riffChunk, error) { + var magic [4]byte + binary.Read(r, binary.LittleEndian, &magic) + if magic == [4]byte{'R','F','6','4'} { + return nil, errors.New("RF64 files are not yet supported") + } + if magic != [4]byte{'R','I','F','F'} { + return nil, errors.New("not a RIFF file") + } + var riffSize uint32 + binary.Read(r, binary.LittleEndian, &riffSize) + var formType [4]byte + binary.Read(r, binary.LittleEndian, &formType) + if formType != [4]byte{'W','A','V','E'} { + return nil, errors.New("not a WAVE file") + } + // Read sub-chunks until EOF or riffSize exhausted + var chunks []riffChunk + for { + var chunkID [4]byte + if err := binary.Read(r, binary.LittleEndian, &chunkID); err != nil { + break // EOF + } + var chunkSize uint32 + binary.Read(r, binary.LittleEndian, &chunkSize) + data := make([]byte, chunkSize) + io.ReadFull(r, data) + chunks = append(chunks, riffChunk{id: chunkID, data: data}) + // Skip padding byte for odd-length chunks + if chunkSize%2 != 0 { + r.Read(make([]byte, 1)) + } + } + return chunks, nil +} +``` + +### Pattern 2: RIFF Chunk Writer (Write All + ID3v2 at End) +**What:** Write RIFF header, all preserved chunks in original order (excluding old `id3 ` chunk), then append new `id3 ` chunk at end. +**When to use:** Inside `fileutil.AtomicWrite()` callback. +**Example:** +```go +func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error { + // Calculate total data size: 4 (WAVE) + sum of (8 + chunkSize + padding) for each chunk + totalDataSize := uint32(4) // "WAVE" form type + for _, c := range chunks { + padded := c.paddedSize() + totalDataSize += 8 + padded + } + // Add id3 chunk + id3PaddedSize := uint32(len(id3Data)) + if id3PaddedSize%2 != 0 { id3PaddedSize++ } + totalDataSize += 8 + id3PaddedSize + + // Check 4GB limit + if uint64(totalDataSize) + 8 > 0xFFFFFFFF { + return errors.New("file too large for WAV format (>4GB)") + } + + // Write RIFF header + w.Write([]byte("RIFF")) + binary.Write(w, binary.LittleEndian, totalDataSize) + w.Write([]byte("WAVE")) + + // Write preserved chunks + for _, c := range chunks { + w.Write(c.id[:]) + binary.Write(w, binary.LittleEndian, uint32(len(c.data))) + w.Write(c.data) + if len(c.data)%2 != 0 { w.Write([]byte{0}) } + } + + // Write id3 chunk + w.Write([]byte("id3 ")) + binary.Write(w, binary.LittleEndian, uint32(len(id3Data))) + w.Write(id3Data) + if len(id3Data)%2 != 0 { w.Write([]byte{0}) } + return nil +} +``` + +### Pattern 3: Reuse MP3 ID3v2 Tag Manipulation +**What:** Extract existing ID3v2 bytes from RIFF `id3 ` chunk, parse with `bogem/id3v2.ParseReader()`, apply changes with existing `applyTextChanges()` + `applyCoverArtChanges()`, serialize with `tag.WriteTo()`. +**When to use:** Core of `writeWavTags()`. +**Example:** +```go +func writeWavTags(logger *slog.Logger, filePath string, changes TagChanges) error { + // 1. Open and parse RIFF chunks + f, _ := os.Open(filePath) + defer f.Close() + chunks, err := parseRIFF(f) + // ... error handling, RF64 check, file size warning + + // 2. Find and extract existing id3 chunk + var existingID3 []byte + var preserved []riffChunk + for _, c := range chunks { + if isID3ChunkID(c.id) { + existingID3 = c.data + } else { + preserved = append(preserved, c) + } + } + + // 3. Parse existing ID3v2 tag or create new empty one + var tag *id3v2.Tag + if len(existingID3) > 0 { + tag, _ = id3v2.ParseReader(bytes.NewReader(existingID3), id3v2.Options{Parse: true}) + } else { + tag = id3v2.NewEmptyTag() + } + + // 4. Apply changes (reuse MP3 functions) + applyTextChanges(tag, changes) + applyCoverArtChanges(tag, changes) + + // 5. Serialize ID3v2 tag to bytes + var id3Buf bytes.Buffer + tag.WriteTo(&id3Buf) + + // 6. Atomic write new RIFF file + return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error { + return writeRIFF(tmp, preserved, id3Buf.Bytes()) + }) +} +``` + +### Pattern 4: Album Artist via TPE2 +**What:** The MP3 writer's `applyTextChanges()` currently does NOT handle `FieldAlbumArtist`. The WAV writer needs it. Map `album_artist` to TPE2 ("Band/Orchestra/Accompaniment") — the de facto standard for album artist in ID3v2. +**When to use:** In a WAV-specific `applyTextChanges` wrapper, or better, fix the gap in the shared `applyTextChanges()`. +**Recommendation:** Add TPE2 mapping to the shared `applyTextChanges()` in mp3.go — this fixes the MP3 writer's missing album_artist support AND gives WAV the same behavior. The FLAC writer already maps this correctly to ALBUMARTIST. + +```go +// Add to applyTextChanges() in mp3.go: +if v, ok := changes[FieldAlbumArtist].(string); ok { + tpe2ID := tag.CommonID("Band/Orchestra/Accompaniment") + tag.DeleteFrames(tpe2ID) + tag.AddTextFrame(tpe2ID, id3v2.EncodingUTF8, v) +} +``` + +### Anti-Patterns to Avoid +- **Trying to use `id3v2.Open()` on WAV files:** It expects "ID3" magic at byte 0; a WAV file starts with "RIFF". This will return an empty tag with no error, silently discarding existing metadata. +- **Using `id3v2.Tag.Save()` for WAV:** Save() seeks past the original ID3v2 tag size and copies "audio data" — this makes no sense for RIFF containers where the ID3v2 tag is a sub-chunk, not a prefix. +- **Calling `dhowden/tag.ReadFrom()` on a WAV file:** Returns `ErrNoTagsFound` because it has no RIFF detection case. Must extract the `id3 ` chunk bytes first. +- **Forgetting the padding byte:** Odd-length RIFF chunks MUST be followed by a padding byte (0x00) for word alignment. Both reading and writing must account for this. +- **Using `ID3 ` (uppercase) chunk ID:** The de facto standard is `id3 ` (lowercase). Some tools write `ID3 ` — reading should accept both (case-insensitive), but writing should use `id3 ` (lowercase). + +## Don't Hand-Roll + +| Problem | Don't Build | Use Instead | Why | +|---------|-------------|-------------|-----| +| ID3v2 tag encoding/decoding | Custom ID3v2 frame parser | `bogem/id3v2/v2` (`NewEmptyTag`, `ParseReader`, `WriteTo`, `AddAttachedPicture`) | ID3v2 has synchsafe integers, multiple encodings (UTF-8, UTF-16, ISO-8859-1), APIC frame structure — deceptively complex | +| Crash-safe file replacement | Manual temp file + rename | `fileutil.AtomicWrite()` | Already handles orphan cleanup, permission preservation, cross-device detection | +| JPEG/PNG detection | Custom image format sniffer | `detectMIME()` in tagwriter.go | Already exists, handles the JPEG (0xFF 0xD8) and PNG (0x89 PNG) magic bytes | + +**Key insight:** The RIFF container parsing IS simple enough to hand-roll (~100 lines). The ID3v2 tag handling is NOT — it absolutely needs the library. The separation is: custom RIFF container, library ID3v2 payload. + +## Common Pitfalls + +### Pitfall 1: Padding Byte Alignment +**What goes wrong:** Odd-length chunks not followed by a padding byte cause subsequent chunks to be misaligned. Some tools are lenient; others break. +**Why it happens:** The RIFF spec requires chunks to start at even byte offsets, but the chunk size field reports the actual data length (not padded). +**How to avoid:** When reading: after reading `chunkSize` bytes of data, if `chunkSize` is odd, read and discard one additional byte. When writing: after writing chunk data, if data length is odd, write one zero byte. +**Warning signs:** Tests pass on files you create but fail on files from real-world tools. + +### Pitfall 2: RIFF Size Field Calculation +**What goes wrong:** The RIFF header's size field must equal the total file size minus 8 (the "RIFF" ID + size field itself). Getting this wrong makes some players reject the file. +**Why it happens:** Easy to forget to include the 4-byte "WAVE" form type in the count, or to miscalculate padding. +**How to avoid:** Calculate total: `4 (WAVE) + Σ(8 + paddedChunkSize)` for all sub-chunks including the `id3 ` chunk. +**Warning signs:** File plays in some players but not others; hex editor shows mismatch between RIFF size and actual file size. + +### Pitfall 3: Chunk ID Case Sensitivity for `id3 ` +**What goes wrong:** The `id3 ` chunk ID is conventionally lowercase, but some tools (notably older versions of Windows Media Player, MediaMonkey) write `ID3 ` (uppercase). If you only look for one case, you miss existing tags. +**Why it happens:** There's no formal standard — the de facto convention varies. +**How to avoid:** Accept both `id3 ` and `ID3 ` on read (case-insensitive comparison for the first 3 bytes). Write `id3 ` (lowercase) — it's the more common convention used by most modern tools. +**Warning signs:** Existing metadata is duplicated instead of replaced on some WAV files. + +### Pitfall 4: 4GB RIFF Size Limit +**What goes wrong:** RIFF uses a 32-bit unsigned size field. Files >4GB cannot be represented. +**Why it happens:** Adding a large cover art image to an already-large WAV file could push it over. +**How to avoid:** Calculate the total output size before writing. If it exceeds `0xFFFFFFFF` (4,294,967,295) bytes, return an error. The atomic write pattern ensures the original file is untouched. +**Warning signs:** Silently truncated files or integer overflow in the size field. + +### Pitfall 5: `bogem/id3v2.ParseReader()` With Empty/No ID3v2 Data +**What goes wrong:** If there's no existing `id3 ` chunk, calling `ParseReader` with empty/nil data would fail. +**Why it happens:** Not all WAV files have an ID3v2 chunk. +**How to avoid:** Check if existing ID3v2 data exists. If not, use `id3v2.NewEmptyTag()` instead of `ParseReader()`. +**Warning signs:** Error on WAV files that have never been tagged. + +### Pitfall 6: dhowden/tag Cannot Read WAV Files +**What goes wrong:** `metadata.ExtractTags()` calls `tag.ReadFrom()` which does NOT detect RIFF format — returns `ErrNoTagsFound` for any WAV file, even one with a valid `id3 ` chunk. +**Why it happens:** `dhowden/tag` v0.0.0-20240417 has no RIFF case in its `ReadFrom()` switch statement. It only checks for "fLaC", "OggS", "ftyp", "ID3", "DSD ". +**How to avoid:** Tests CANNOT use `metadata.ExtractTags()` for WAV read-back. Instead, write a test helper that: (1) parses RIFF chunks, (2) extracts `id3 ` chunk data, (3) passes it to `tag.ReadID3v2Tags()` via `bytes.NewReader()`. This gives the same `Metadata` interface used by other tests. +**Warning signs:** All WAV round-trip tests fail with "no tags found" even though the write succeeded. + +## Code Examples + +### Test WAV Fixture Construction +```go +// createTestWAV builds a minimal valid WAV file with optional initial +// ID3v2 metadata. The file contains a minimal PCM fmt chunk and a +// short silence data chunk, plus an id3 chunk if fields are provided. +func createTestWAV(t *testing.T, dir, name string, fields TagChanges) string { + t.Helper() + path := filepath.Join(dir, name) + var buf bytes.Buffer + + // fmt chunk: PCM, mono, 44100 Hz, 16-bit + fmtData := []byte{ + 0x01, 0x00, // AudioFormat: PCM + 0x01, 0x00, // NumChannels: 1 + 0x44, 0xAC, 0x00, 0x00, // SampleRate: 44100 + 0x88, 0x58, 0x01, 0x00, // ByteRate: 88200 + 0x02, 0x00, // BlockAlign: 2 + 0x10, 0x00, // BitsPerSample: 16 + } + + // data chunk: 100 samples of silence (200 bytes) + audioData := make([]byte, 200) + + // Build ID3v2 chunk if fields provided + var id3Data []byte + if len(fields) > 0 { + tag := id3v2.NewEmptyTag() + tag.SetDefaultEncoding(id3v2.EncodingUTF8) + applyTextChanges(tag, fields) + applyCoverArtChanges(tag, fields) + var id3Buf bytes.Buffer + tag.WriteTo(&id3Buf) + id3Data = id3Buf.Bytes() + } + + // Calculate total RIFF data size + riffDataSize := uint32(4) // "WAVE" + riffDataSize += 8 + uint32(len(fmtData)) + riffDataSize += 8 + uint32(len(audioData)) + if len(id3Data) > 0 { + riffDataSize += 8 + uint32(len(id3Data)) + if len(id3Data)%2 != 0 { riffDataSize++ } + } + + // Write RIFF header + buf.Write([]byte("RIFF")) + binary.Write(&buf, binary.LittleEndian, riffDataSize) + buf.Write([]byte("WAVE")) + + // Write fmt chunk + buf.Write([]byte("fmt ")) + binary.Write(&buf, binary.LittleEndian, uint32(len(fmtData))) + buf.Write(fmtData) + + // Write data chunk + buf.Write([]byte("data")) + binary.Write(&buf, binary.LittleEndian, uint32(len(audioData))) + buf.Write(audioData) + + // Write id3 chunk if present + if len(id3Data) > 0 { + buf.Write([]byte("id3 ")) + binary.Write(&buf, binary.LittleEndian, uint32(len(id3Data))) + buf.Write(id3Data) + if len(id3Data)%2 != 0 { buf.WriteByte(0) } + } + + os.WriteFile(path, buf.Bytes(), 0o644) + return path +} +``` + +### Test Read-Back Helper (Extract ID3v2 from WAV) +```go +// readWavID3Tags extracts the id3 chunk from a WAV file and parses +// it with dhowden/tag for test verification, returning the same +// Metadata interface used by other tagwriter tests. +func readWavID3Tags(t *testing.T, path string) *metadata.TrackMetadata { + t.Helper() + f, _ := os.Open(path) + defer f.Close() + + chunks, err := parseRIFF(f) + if err != nil { t.Fatalf("parseRIFF: %v", err) } + + for _, c := range chunks { + if isID3ChunkID(c.id) { + r := bytes.NewReader(c.data) + m, err := tag.ReadID3v2Tags(r) + if err != nil { t.Fatalf("ReadID3v2Tags: %v", err) } + // Convert dhowden/tag Metadata to our TrackMetadata + trackNum, _ := m.Track() + discNum, _ := m.Disc() + meta := &metadata.TrackMetadata{ + Title: m.Title(), Artist: m.Artist(), + Album: m.Album(), AlbumArtist: m.AlbumArtist(), + Genre: m.Genre(), Year: m.Year(), + TrackNumber: trackNum, DiscNumber: discNum, + Composer: m.Composer(), + } + if pic := m.Picture(); pic != nil { + meta.Picture = &metadata.PictureData{ + Data: pic.Data, MIMEType: pic.MIMEType, Ext: pic.Ext, + } + } + return meta + } + } + t.Fatal("no id3 chunk found in WAV file") + return nil +} +``` + +## State of the Art + +| Old Approach | Current Approach | When Changed | Impact | +|--------------|------------------|--------------|--------| +| RIFF INFO chunks for WAV metadata | ID3v2-in-RIFF via `id3 ` chunk | ~2010 era | ID3v2 supports album_artist, cover art, disc number — INFO cannot. All modern taggers (foobar2000, MusicBrainz Picard, Mp3tag) use ID3v2 in WAV. | +| Various case conventions for ID3 chunk ID | `id3 ` (lowercase) is de facto standard | Gradual convergence | Write lowercase; accept both on read for compatibility | + +**Deprecated/outdated:** +- RIFF INFO as primary metadata target: Cannot represent album_artist, disc_number, or embedded cover art. Explicitly deferred (FMT-03). + +## Open Questions + +1. **Album artist in MP3 writer** + - What we know: The MP3 writer's `applyTextChanges()` does NOT map `FieldAlbumArtist` to TPE2. The FLAC writer correctly maps it to ALBUMARTIST. The MP3 test suite doesn't test album_artist. + - What's unclear: Is this an intentional omission (handled at a different layer) or a bug? + - Recommendation: Add TPE2 mapping to `applyTextChanges()` in mp3.go as part of this phase. The WAV writer reuses this function, so it needs album_artist support. This also fixes a latent gap in MP3 writing. + +2. **dhowden/tag WAV support in metadata.ExtractTags()** + - What we know: `dhowden/tag` does not support WAV/RIFF. The project's `metadata.ExtractTags()` wraps `tag.ReadFrom()` which will return `ErrNoTagsFound` for WAV files. + - What's unclear: Will the existing library scanner (`metadata.ExtractAllMetadata`) work for WAV tag reading during library scans? (This is an existing issue unrelated to writing, but worth noting.) + - Recommendation: For this phase, only solve it in tests via a custom RIFF extraction helper. The broader metadata reading issue is separate. + +## Sources + +### Primary (HIGH confidence) +- `bogem/id3v2/v2` source code (v2.1.4) — verified: `Open()` calls `os.Open` + `ParseReader`; `ParseReader` calls `tag.parse()` which calls `parseHeader()` checking for "ID3" magic; `WriteTo()` writes raw ID3v2 bytes; `Save()` assumes MP3 structure (seeks past original tag, copies audio). **No WAV/RIFF support.** +- `dhowden/tag` source code (v0.0.0-20240417053706) — verified: `ReadFrom()` switch has no RIFF case; `ReadID3v2Tags()` accepts `io.ReadSeeker` starting with "ID3" magic. **No WAV/RIFF support, but ReadID3v2Tags works on extracted chunk data.** +- RIFF format specification (Multimedia Programming Interface and Data Specifications 1.0, IBM/Microsoft, August 1991) — chunk structure: 4-byte ID + 4-byte LE size + data + optional padding byte +- `golang.org/x/image/riff` package — confirmed read-only; `NewReader` returns streaming `Reader` with no write capability + +### Secondary (MEDIUM confidence) +- Wikipedia RIFF article — confirms chunk structure, padding rules, RF64 extension mechanism +- Library of Congress WAV format description — confirms magic bytes `RIFF....WAVE`, RIFF size is 32-bit LE + +### Tertiary (LOW confidence) +- Convention that `id3 ` (lowercase) is preferred over `ID3 ` — based on observed behavior of major taggers (foobar2000, MusicBrainz Picard, Mp3tag). No formal spec mandates case. + +## Metadata + +**Confidence breakdown:** +- Standard stack: HIGH — verified by reading actual library source code; no WAV support in either id3v2 or dhowden/tag, confirmed by inspecting parse/detect logic +- Architecture: HIGH — RIFF format is well-understood, simple structure; implementation pattern directly mirrors existing MP3/FLAC writers +- Pitfalls: HIGH — padding byte rule verified in RIFF spec; 4GB limit is inherent to 32-bit size field; dhowden/tag WAV gap verified by source inspection + +**Research date:** 2026-03-18 +**Valid until:** 2026-04-18 (stable domain — RIFF spec unchanged since 1991, library versions pinned in go.mod)