docs(19): create phase plan for WAV tag writer
This commit is contained in:
@@ -68,7 +68,11 @@
|
|||||||
3. Editing a WAV file's tags does not alter audio playback — the file sounds identical before and after
|
3. Editing a WAV file's tags does not alter audio playback — the file sounds identical before and after
|
||||||
4. Existing metadata in the WAV file that wasn't edited (RIFF INFO chunks, bext, cue markers) survives the tag write unchanged
|
4. Existing metadata in the WAV file that wasn't edited (RIFF INFO chunks, bext, cue markers) survives the tag write unchanged
|
||||||
5. If the app crashes or loses power during a WAV tag write, the original file is intact (not corrupted or truncated)
|
5. If the app crashes or loses power during a WAV tag write, the original file is intact (not corrupted or truncated)
|
||||||
**Plans**: TBD
|
**Plans:** 2 plans
|
||||||
|
|
||||||
|
Plans:
|
||||||
|
- [ ] 19-01-PLAN.md — WAV RIFF parser/writer and writeWavTags function
|
||||||
|
- [ ] 19-02-PLAN.md — WAV tag writer round-trip tests
|
||||||
|
|
||||||
### Phase 20: OGG Vorbis Tag Writer
|
### Phase 20: OGG Vorbis Tag Writer
|
||||||
**Goal**: Users can edit metadata and cover art on OGG Vorbis files with the same experience as MP3/FLAC/WAV
|
**Goal**: Users can edit metadata and cover art on OGG Vorbis files with the same experience as MP3/FLAC/WAV
|
||||||
@@ -113,7 +117,7 @@
|
|||||||
| 16. Tag Writing & Database Sync | v1.2 | 3/3 | Complete | 2026-03-17 |
|
| 16. Tag Writing & Database Sync | v1.2 | 3/3 | Complete | 2026-03-17 |
|
||||||
| 17. Single Track Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
| 17. Single Track Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
||||||
| 18. Batch Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
| 18. Batch Edit | v1.2 | 2/2 | Complete | 2026-03-18 |
|
||||||
| 19. WAV Tag Writer | v1.2.1 | 0/? | Not started | - |
|
| 19. WAV Tag Writer | v1.2.1 | 0/2 | Not started | - |
|
||||||
| 20. OGG Vorbis Tag Writer | v1.2.1 | 0/? | Not started | - |
|
| 20. OGG Vorbis Tag Writer | v1.2.1 | 0/? | Not started | - |
|
||||||
| 21. Cleanup | v1.2.1 | 0/? | Not started | - |
|
| 21. Cleanup | v1.2.1 | 0/? | Not started | - |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,283 @@
|
|||||||
|
---
|
||||||
|
phase: 19-wav-tag-writer
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- backend/tagwriter/wav.go
|
||||||
|
- backend/tagwriter/tagwriter.go
|
||||||
|
- backend/tagwriter/mp3.go
|
||||||
|
- backend/tagwriter/pipeline.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [WAV-01, WAV-02, WAV-03, WAV-04, WAV-05]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "writeWavTags() writes ID3v2 metadata to a WAV file via a RIFF id3 chunk"
|
||||||
|
- "All non-ID3v2 RIFF chunks are preserved byte-for-byte in original order"
|
||||||
|
- "RF64 files are rejected with a clear error message"
|
||||||
|
- "Files >4GB after write are rejected before writing"
|
||||||
|
- "Existing ID3v2 tags in the WAV are merged (unknown frames preserved)"
|
||||||
|
- "Album artist is mapped to TPE2 for all ID3v2 writers (MP3 and WAV)"
|
||||||
|
- "DetectFormat returns FormatWAV for .wav files"
|
||||||
|
- "Pipeline dispatches to writeWavTags for WAV format"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/tagwriter/wav.go"
|
||||||
|
provides: "RIFF chunk parser, RIFF writer, writeWavTags function"
|
||||||
|
exports: ["writeWavTags"]
|
||||||
|
- path: "backend/tagwriter/tagwriter.go"
|
||||||
|
provides: "FormatWAV constant, .wav case in DetectFormat"
|
||||||
|
contains: "FormatWAV"
|
||||||
|
- path: "backend/tagwriter/mp3.go"
|
||||||
|
provides: "TPE2 album_artist mapping in applyTextChanges"
|
||||||
|
contains: "FieldAlbumArtist"
|
||||||
|
- path: "backend/tagwriter/pipeline.go"
|
||||||
|
provides: "FormatWAV dispatch case in WriteTrackTags"
|
||||||
|
contains: "FormatWAV"
|
||||||
|
key_links:
|
||||||
|
- from: "backend/tagwriter/pipeline.go"
|
||||||
|
to: "backend/tagwriter/wav.go"
|
||||||
|
via: "case FormatWAV in format dispatch switch"
|
||||||
|
pattern: "case FormatWAV.*writeWavTags"
|
||||||
|
- from: "backend/tagwriter/wav.go"
|
||||||
|
to: "backend/tagwriter/mp3.go"
|
||||||
|
via: "reuse applyTextChanges and applyCoverArtChanges"
|
||||||
|
pattern: "applyTextChanges|applyCoverArtChanges"
|
||||||
|
- from: "backend/tagwriter/wav.go"
|
||||||
|
to: "backend/fileutil/atomicwrite.go"
|
||||||
|
via: "fileutil.AtomicWrite for crash-safe writes"
|
||||||
|
pattern: "fileutil\\.AtomicWrite"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Implement the WAV tag writer: a custom RIFF chunk parser/writer that preserves all non-ID3v2 chunks byte-for-byte and embeds ID3v2 metadata via the `id3 ` chunk, using `bogem/id3v2` for tag manipulation and `fileutil.AtomicWrite` for crash safety.
|
||||||
|
|
||||||
|
Purpose: Enable WAV files to be edited with the same metadata pipeline as MP3/FLAC — this is the core backend capability for Phase 19.
|
||||||
|
Output: `wav.go` with RIFF parser + writer + writeWavTags; updated `tagwriter.go` + `pipeline.go` with FormatWAV support; fixed `mp3.go` with album_artist TPE2 mapping.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/19-wav-tag-writer/19-RESEARCH.md
|
||||||
|
@.planning/phases/19-wav-tag-writer/19-CONTEXT.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||||
|
|
||||||
|
From backend/tagwriter/tagwriter.go:
|
||||||
|
```go
|
||||||
|
type TagChanges map[string]any
|
||||||
|
|
||||||
|
const (
|
||||||
|
FieldTitle = "title"
|
||||||
|
FieldArtist = "artist"
|
||||||
|
FieldAlbum = "album"
|
||||||
|
FieldAlbumArtist = "album_artist"
|
||||||
|
FieldGenre = "genre"
|
||||||
|
FieldYear = "year"
|
||||||
|
FieldTrackNumber = "track_number"
|
||||||
|
FieldDiscNumber = "disc_number"
|
||||||
|
FieldComposer = "composer"
|
||||||
|
FieldCoverArt = "cover_art"
|
||||||
|
)
|
||||||
|
|
||||||
|
type AudioFormat string
|
||||||
|
|
||||||
|
const (
|
||||||
|
FormatMP3 AudioFormat = "mp3"
|
||||||
|
FormatFLAC AudioFormat = "flac"
|
||||||
|
)
|
||||||
|
|
||||||
|
func DetectFormat(filePath string) (AudioFormat, error)
|
||||||
|
func asInt(v any) (int, bool)
|
||||||
|
func asBytes(v any) ([]byte, bool)
|
||||||
|
func detectMIME(data []byte) string
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/mp3.go:
|
||||||
|
```go
|
||||||
|
func writeMp3Tags(logger *slog.Logger, filePath string, changes TagChanges) error
|
||||||
|
func applyTextChanges(tag *id3v2.Tag, changes TagChanges) // reuse for WAV
|
||||||
|
func applyCoverArtChanges(tag *id3v2.Tag, changes TagChanges) // reuse for WAV
|
||||||
|
func copyAudioData(originalPath string, tagSize int64, dst *os.File) error
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/pipeline.go:
|
||||||
|
```go
|
||||||
|
// Format dispatch switch (lines 142-149):
|
||||||
|
switch format {
|
||||||
|
case FormatMP3:
|
||||||
|
err = writeMp3Tags(tw.logger, audioFile.FilePath, changes)
|
||||||
|
case FormatFLAC:
|
||||||
|
err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
|
||||||
|
default:
|
||||||
|
err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/fileutil/atomicwrite.go:
|
||||||
|
```go
|
||||||
|
func AtomicWrite(logger *slog.Logger, targetPath string, fn func(tmp *os.File) error) (err error)
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/flac.go (pattern reference):
|
||||||
|
```go
|
||||||
|
func writeFlacTags(logger *slog.Logger, filePath string, changes TagChanges) error
|
||||||
|
// Pattern: parse file → modify metadata → AtomicWrite callback → f.WriteTo(tmp)
|
||||||
|
// Warns above 500MB via slog
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Fix album_artist TPE2 mapping in shared applyTextChanges</name>
|
||||||
|
<files>backend/tagwriter/mp3.go</files>
|
||||||
|
<action>
|
||||||
|
Add the missing `FieldAlbumArtist` → TPE2 mapping to `applyTextChanges()` in mp3.go. This fixes a latent gap in MP3 writing AND enables WAV to reuse the same function. Insert after the `FieldComposer` block (around line 83):
|
||||||
|
|
||||||
|
```go
|
||||||
|
if v, ok := changes[FieldAlbumArtist].(string); ok {
|
||||||
|
tpe2ID := tag.CommonID("Band/Orchestra/Accompaniment")
|
||||||
|
tag.DeleteFrames(tpe2ID)
|
||||||
|
tag.AddTextFrame(tpe2ID, id3v2.EncodingUTF8, v)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This follows the exact same pattern as the existing FieldComposer → TCOM mapping. The `CommonID("Band/Orchestra/Accompaniment")` resolves to "TPE2" which is the standard ID3v2 frame for album artist.
|
||||||
|
|
||||||
|
Also verify the line length stays under 100 chars (golines linter). Break the tag.AddTextFrame call across lines if needed.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go test -tags webkit2_41 -run TestWriteMp3Tags ./backend/tagwriter/ -count=1</automated>
|
||||||
|
</verify>
|
||||||
|
<done>applyTextChanges handles FieldAlbumArtist via TPE2 frame; existing MP3 tests still pass</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Create WAV RIFF parser/writer and writeWavTags function</name>
|
||||||
|
<files>backend/tagwriter/wav.go, backend/tagwriter/tagwriter.go, backend/tagwriter/pipeline.go</files>
|
||||||
|
<action>
|
||||||
|
**Step 1: Add FormatWAV to tagwriter.go**
|
||||||
|
|
||||||
|
Add the WAV format constant after FormatFLAC:
|
||||||
|
```go
|
||||||
|
// FormatWAV is the WAV audio format.
|
||||||
|
FormatWAV AudioFormat = "wav"
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `.wav` case to DetectFormat switch:
|
||||||
|
```go
|
||||||
|
case ".wav":
|
||||||
|
return FormatWAV, nil
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Add dispatch case to pipeline.go**
|
||||||
|
|
||||||
|
In the WriteTrackTags format switch (after `case FormatFLAC:`), add:
|
||||||
|
```go
|
||||||
|
case FormatWAV:
|
||||||
|
err = writeWavTags(tw.logger, audioFile.FilePath, changes)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Create wav.go with RIFF parser/writer and writeWavTags**
|
||||||
|
|
||||||
|
Create `backend/tagwriter/wav.go` with:
|
||||||
|
|
||||||
|
1. Package doc comment, imports (`bytes`, `encoding/binary`, `errors`, `fmt`, `io`, `log/slog`, `os`, `github.com/bogem/id3v2/v2`, `yellowjacket/backend/fileutil`)
|
||||||
|
|
||||||
|
2. Sentinel errors:
|
||||||
|
- `errRF64NotSupported = errors.New("RF64 files are not yet supported")`
|
||||||
|
- `errNotRIFF = errors.New("not a RIFF file")`
|
||||||
|
- `errNotWAVE = errors.New("not a WAVE file")`
|
||||||
|
- `errFileTooLargeForWAV = errors.New("file too large for WAV format (>4GB)")`
|
||||||
|
|
||||||
|
3. `riffChunk` struct: `type riffChunk struct { id [4]byte; data []byte }`
|
||||||
|
|
||||||
|
4. `parseRIFF(r io.ReadSeeker) ([]riffChunk, error)`:
|
||||||
|
- Read 4-byte magic. If `RF64` → return errRF64NotSupported. If not `RIFF` → return errNotRIFF.
|
||||||
|
- Read 4-byte uint32 LE riffSize (read but don't enforce — lenient read per user decision).
|
||||||
|
- Read 4-byte form type. If not `WAVE` → return errNotWAVE.
|
||||||
|
- Loop reading chunks until EOF:
|
||||||
|
- Read 4-byte chunk ID + 4-byte uint32 LE chunk size.
|
||||||
|
- Read `chunkSize` bytes of data via `io.ReadFull`.
|
||||||
|
- Append `riffChunk{id, data}`.
|
||||||
|
- If `chunkSize` is odd, skip 1 padding byte (lenient: if read fails, break — don't error).
|
||||||
|
- Return chunks slice.
|
||||||
|
|
||||||
|
5. `isID3ChunkID(id [4]byte) bool`:
|
||||||
|
- Case-insensitive check: returns true if first 3 bytes are 'i','d','3' or 'I','D','3' (accept both `id3 ` and `ID3 ` per research).
|
||||||
|
|
||||||
|
6. `writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error`:
|
||||||
|
- Calculate total RIFF data size: `4` (WAVE) + for each chunk: `8 + len(c.data) + padding`. For `id3 ` chunk: `8 + len(id3Data) + padding`.
|
||||||
|
- Check 4GB limit: if total + 8 > 0xFFFFFFFF, return errFileTooLargeForWAV.
|
||||||
|
- Write `RIFF` + uint32 LE total size + `WAVE`.
|
||||||
|
- Write each preserved chunk: ID + uint32 LE len(data) + data + padding byte if odd.
|
||||||
|
- Write `id3 ` chunk: `id3 ` + uint32 LE len(id3Data) + id3Data + padding byte if odd.
|
||||||
|
- Return nil on success.
|
||||||
|
|
||||||
|
7. `writeWavTags(logger *slog.Logger, filePath string, changes TagChanges) error`:
|
||||||
|
- Stat file. If size > 500MB, log warning via slog (same threshold as FLAC writer).
|
||||||
|
- Open file for reading, defer close.
|
||||||
|
- Call `parseRIFF(f)`. On error, return friendly wrapped error.
|
||||||
|
- Separate chunks: iterate all chunks, collect non-ID3 chunks into `preserved` slice and extract ID3 data from any `id3 ` chunk (using `isID3ChunkID`).
|
||||||
|
- Build ID3v2 tag:
|
||||||
|
- If existing ID3 data found: `id3v2.ParseReader(bytes.NewReader(existingID3), id3v2.Options{Parse: true})`
|
||||||
|
- If no existing ID3 data: `id3v2.NewEmptyTag()` with `tag.SetDefaultEncoding(id3v2.EncodingUTF8)`
|
||||||
|
- Apply changes: `applyTextChanges(tag, changes)` + `applyCoverArtChanges(tag, changes)` (reusing MP3 functions).
|
||||||
|
- Serialize tag: `var id3Buf bytes.Buffer` → `tag.WriteTo(&id3Buf)`.
|
||||||
|
- Close the source file (release handle before AtomicWrite).
|
||||||
|
- Call `fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error { return writeRIFF(tmp, preserved, id3Buf.Bytes()) })`.
|
||||||
|
|
||||||
|
**Critical implementation details (from CONTEXT.md locked decisions):**
|
||||||
|
- Preserve ALL non-ID3v2 chunks byte-for-byte in original order.
|
||||||
|
- ID3v2 chunk placed at END of file.
|
||||||
|
- Read and merge existing ID3v2 tags (preserve unknown frames from other tools).
|
||||||
|
- Lenient read (accept missing padding, ignore RIFF size mismatch), strict write (correct padding, correct sizes).
|
||||||
|
- Write `id3 ` (lowercase) chunk ID.
|
||||||
|
- Accept both `id3 ` and `ID3 ` on read.
|
||||||
|
|
||||||
|
**Linting requirements:**
|
||||||
|
- All doc comments end with period (godot).
|
||||||
|
- Sentinel errors as package-level vars (err113).
|
||||||
|
- Blank line after early returns (nlreturn).
|
||||||
|
- Lines under 100 chars (golines).
|
||||||
|
- No cuddled var declarations (wsl).
|
||||||
|
- Import groups: stdlib, third-party, internal (gci).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go build -tags webkit2_41 ./backend/tagwriter/ && go vet -tags webkit2_41 ./backend/tagwriter/</automated>
|
||||||
|
</verify>
|
||||||
|
<done>wav.go exists with parseRIFF, writeRIFF, writeWavTags; tagwriter.go has FormatWAV constant and .wav DetectFormat case; pipeline.go dispatches FormatWAV to writeWavTags; package compiles and vets clean</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go build -tags webkit2_41 ./backend/tagwriter/` succeeds
|
||||||
|
- `go vet -tags webkit2_41 ./backend/tagwriter/` has no issues
|
||||||
|
- `go test -tags webkit2_41 -run TestWriteMp3Tags ./backend/tagwriter/ -count=1` passes (existing tests not broken)
|
||||||
|
- `go test -tags webkit2_41 -run TestWriteFlacTags ./backend/tagwriter/ -count=1` passes (existing tests not broken)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- FormatWAV is detected for `.wav` extension
|
||||||
|
- writeWavTags compiles and is reachable from pipeline dispatch
|
||||||
|
- RIFF parser handles: valid WAV, RF64 rejection, non-RIFF rejection
|
||||||
|
- RIFF writer produces correct chunk structure with padding
|
||||||
|
- Existing MP3 and FLAC tests pass (no regressions)
|
||||||
|
- applyTextChanges handles album_artist via TPE2
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/19-wav-tag-writer/19-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
---
|
||||||
|
phase: 19-wav-tag-writer
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: ["19-01"]
|
||||||
|
files_modified:
|
||||||
|
- backend/tagwriter/wav_test.go
|
||||||
|
autonomous: true
|
||||||
|
requirements: [WAV-01, WAV-02, WAV-03, WAV-04, WAV-05, WAV-06]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "WAV text fields round-trip: write 8 fields → read back all 8 with correct values"
|
||||||
|
- "WAV cover art round-trip: embed JPEG → read back identical bytes and MIME type"
|
||||||
|
- "WAV clear cover art: embed then clear → no picture data on read-back"
|
||||||
|
- "WAV partial update: change 2 of 8 fields → other 6 fields preserved"
|
||||||
|
- "WAV chunk preservation: non-ID3v2 chunks (fmt, data, LIST INFO, bext) survive tag write unchanged"
|
||||||
|
- "WAV atomic safety: failed write leaves original file untouched"
|
||||||
|
- "RF64 files are rejected with clear error"
|
||||||
|
- "All tests pass via make test (no regressions across entire suite)"
|
||||||
|
artifacts:
|
||||||
|
- path: "backend/tagwriter/wav_test.go"
|
||||||
|
provides: "createTestWAV fixture builder, readWavID3Tags read-back helper, 7+ test functions"
|
||||||
|
min_lines: 200
|
||||||
|
key_links:
|
||||||
|
- from: "backend/tagwriter/wav_test.go"
|
||||||
|
to: "backend/tagwriter/wav.go"
|
||||||
|
via: "calls writeWavTags, parseRIFF, isID3ChunkID"
|
||||||
|
pattern: "writeWavTags|parseRIFF|isID3ChunkID"
|
||||||
|
- from: "backend/tagwriter/wav_test.go"
|
||||||
|
to: "backend/tagwriter/helpers_test.go"
|
||||||
|
via: "uses tinyJPEG, testLogger, assertEqual, assertStrField, assertIntField"
|
||||||
|
pattern: "tinyJPEG|testLogger|assertEqual|assertStrField|assertIntField"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create comprehensive round-trip tests for the WAV tag writer, verifying all 6 WAV requirements via automated tests that mirror the existing MP3 and FLAC test patterns.
|
||||||
|
|
||||||
|
Purpose: Prove that WAV tag writing works correctly for all fields, cover art, partial updates, chunk preservation, and atomic safety — completing the WAV-06 requirement.
|
||||||
|
Output: `wav_test.go` with test fixture builder, read-back helper, and 7+ test functions.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||||
|
@/home/caleb/.config/opencode/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/19-wav-tag-writer/19-RESEARCH.md
|
||||||
|
@.planning/phases/19-wav-tag-writer/19-CONTEXT.md
|
||||||
|
@.planning/phases/19-wav-tag-writer/19-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Key types from Plan 01 output that tests need. -->
|
||||||
|
|
||||||
|
From backend/tagwriter/wav.go (created in Plan 01):
|
||||||
|
```go
|
||||||
|
type riffChunk struct {
|
||||||
|
id [4]byte
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseRIFF(r io.ReadSeeker) ([]riffChunk, error)
|
||||||
|
func isID3ChunkID(id [4]byte) bool
|
||||||
|
func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error
|
||||||
|
func writeWavTags(logger *slog.Logger, filePath string, changes TagChanges) error
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/tagwriter.go:
|
||||||
|
```go
|
||||||
|
type TagChanges map[string]any
|
||||||
|
const FieldTitle, FieldArtist, FieldAlbum, FieldAlbumArtist, FieldGenre = ...
|
||||||
|
const FieldYear, FieldTrackNumber, FieldDiscNumber, FieldComposer, FieldCoverArt = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/mp3.go (shared functions used by writeWavTags):
|
||||||
|
```go
|
||||||
|
func applyTextChanges(tag *id3v2.Tag, changes TagChanges) // includes TPE2 album_artist
|
||||||
|
func applyCoverArtChanges(tag *id3v2.Tag, changes TagChanges)
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/tagwriter/helpers_test.go:
|
||||||
|
```go
|
||||||
|
func testLogger() *slog.Logger
|
||||||
|
func tinyJPEG(t *testing.T) []byte
|
||||||
|
func makeMinimalJPEG(t *testing.T) []byte
|
||||||
|
func assertEqual[T comparable](t *testing.T, field string, want, got T)
|
||||||
|
func assertStrField(t *testing.T, name, got, want string)
|
||||||
|
func assertIntField(t *testing.T, name string, got, want int)
|
||||||
|
```
|
||||||
|
|
||||||
|
From backend/metadata/tags.go (TrackMetadata struct for test assertions):
|
||||||
|
```go
|
||||||
|
type TrackMetadata struct {
|
||||||
|
Title, Artist, Album, AlbumArtist, Composer, Genre string
|
||||||
|
Year, TrackNumber, DiscNumber int
|
||||||
|
Picture *PictureData
|
||||||
|
}
|
||||||
|
type PictureData struct {
|
||||||
|
Data []byte; MIMEType string; Ext string
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Test pattern reference — from backend/tagwriter/mp3_test.go:
|
||||||
|
```go
|
||||||
|
func createTestMP3(t *testing.T, dir string, name string, fields TagChanges) string
|
||||||
|
// Creates minimal MP3: id3v2.NewEmptyTag() + applyTextChanges + WriteTo + MPEG frame
|
||||||
|
// Tests: TextFields, CoverArt, ClearCoverArt, PartialUpdate, AtomicSafety
|
||||||
|
// Read-back: metadata.ExtractTags(path) → assert fields
|
||||||
|
```
|
||||||
|
|
||||||
|
Test pattern reference — from backend/tagwriter/flac_test.go:
|
||||||
|
```go
|
||||||
|
func makeMinimalFLAC(t *testing.T, path string)
|
||||||
|
// Tests: TextFields, CoverArt, ClearCoverArt, PartialUpdate, PreservesStreamInfo, ReplaceComment, AtomicSafety
|
||||||
|
// Read-back: metadata.ExtractTags(path) → assert fields
|
||||||
|
```
|
||||||
|
|
||||||
|
CRITICAL NOTE from RESEARCH.md:
|
||||||
|
```
|
||||||
|
dhowden/tag does NOT support WAV files — tag.ReadFrom() returns ErrNoTagsFound.
|
||||||
|
Tests CANNOT use metadata.ExtractTags() for WAV read-back.
|
||||||
|
Must extract id3 chunk from RIFF, then use tag.ReadID3v2Tags() on extracted bytes.
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create WAV test fixture builder and read-back helper</name>
|
||||||
|
<files>backend/tagwriter/wav_test.go</files>
|
||||||
|
<action>
|
||||||
|
Create `backend/tagwriter/wav_test.go` with package `tagwriter` (internal test — matches mp3_test.go and flac_test.go pattern).
|
||||||
|
|
||||||
|
**Imports needed:** `bytes`, `encoding/binary`, `os`, `path/filepath`, `testing`, `github.com/bogem/id3v2/v2`, `github.com/dhowden/tag`, `yellowjacket/backend/metadata` (for TrackMetadata/PictureData types).
|
||||||
|
|
||||||
|
**1. createTestWAV(t *testing.T, dir, name string, fields TagChanges) string**
|
||||||
|
|
||||||
|
Builds a minimal valid WAV file programmatically (same pattern as createTestMP3):
|
||||||
|
|
||||||
|
```
|
||||||
|
RIFF header (12 bytes):
|
||||||
|
"RIFF" + uint32 LE total_data_size + "WAVE"
|
||||||
|
|
||||||
|
fmt chunk (24 bytes):
|
||||||
|
"fmt " + uint32(16) + 16 bytes PCM format:
|
||||||
|
AudioFormat=1 (PCM), NumChannels=1, SampleRate=44100,
|
||||||
|
ByteRate=88200, BlockAlign=2, BitsPerSample=16
|
||||||
|
|
||||||
|
data chunk (208 bytes):
|
||||||
|
"data" + uint32(200) + 200 bytes of silence (zeros)
|
||||||
|
|
||||||
|
id3 chunk (if fields provided):
|
||||||
|
"id3 " + uint32(len) + id3v2 tag bytes + padding if odd
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the ID3v2 tag using `id3v2.NewEmptyTag()` + `tag.SetDefaultEncoding(id3v2.EncodingUTF8)` + `applyTextChanges(tag, fields)` + `applyCoverArtChanges(tag, fields)` + `tag.WriteTo(&id3Buf)`.
|
||||||
|
|
||||||
|
Calculate riffDataSize: 4 (WAVE) + 24 (fmt) + 208 (data) + id3ChunkSize. Write all chunks, then write file to `filepath.Join(dir, name)` with `os.WriteFile(path, buf.Bytes(), 0o644)`. Return path.
|
||||||
|
|
||||||
|
**2. readWavID3Tags(t *testing.T, path string) *metadata.TrackMetadata**
|
||||||
|
|
||||||
|
Test helper that extracts ID3v2 metadata from a WAV file for read-back verification:
|
||||||
|
|
||||||
|
- Open file, call `parseRIFF(f)`.
|
||||||
|
- Find the chunk where `isID3ChunkID(c.id)` is true.
|
||||||
|
- Call `tag.ReadID3v2Tags(bytes.NewReader(c.data))` (from `github.com/dhowden/tag`).
|
||||||
|
- Convert `tag.Metadata` to `*metadata.TrackMetadata`:
|
||||||
|
- `m.Title()`, `m.Artist()`, `m.Album()`, `m.AlbumArtist()`, `m.Composer()`, `m.Genre()`, `m.Year()`
|
||||||
|
- `m.Track()` → trackNum, `m.Disc()` → discNum
|
||||||
|
- `m.Picture()` → PictureData if non-nil
|
||||||
|
- Return the TrackMetadata.
|
||||||
|
- If no id3 chunk found, `t.Fatal("no id3 chunk found in WAV file")`.
|
||||||
|
|
||||||
|
**3. createTestWAVWithExtraChunks(t *testing.T, dir, name string) string**
|
||||||
|
|
||||||
|
Creates a WAV file with additional non-standard chunks to test chunk preservation:
|
||||||
|
|
||||||
|
Build the same base WAV as createTestWAV (no fields), but insert these additional chunks between fmt and data:
|
||||||
|
- LIST INFO chunk: `LIST` + size + `INFO` + `INAM` sub-chunk with "Test Track Name"
|
||||||
|
- A fake `bext` chunk: `bext` + 8 bytes of test data
|
||||||
|
|
||||||
|
This tests that writeWavTags preserves chunks it doesn't understand.
|
||||||
|
|
||||||
|
All test helpers call `t.Helper()` at the start.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go build -tags webkit2_41 ./backend/tagwriter/</automated>
|
||||||
|
</verify>
|
||||||
|
<done>wav_test.go compiles with createTestWAV, readWavID3Tags, and createTestWAVWithExtraChunks helpers</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Write round-trip tests for all WAV requirements</name>
|
||||||
|
<files>backend/tagwriter/wav_test.go</files>
|
||||||
|
<action>
|
||||||
|
Add the following test functions to `wav_test.go`, mirroring the MP3/FLAC test patterns:
|
||||||
|
|
||||||
|
**TestWriteWavTags_TextFields** (covers WAV-01):
|
||||||
|
- Create bare WAV fixture with `createTestWAV(t, dir, "text.wav", nil)`.
|
||||||
|
- Write all 8+1 text fields: Title, Artist, Album, AlbumArtist, Genre, Year(2024), TrackNumber(3), DiscNumber(1), Composer.
|
||||||
|
- Call `writeWavTags(testLogger(), path, changes)`.
|
||||||
|
- Read back with `readWavID3Tags(t, path)`.
|
||||||
|
- Assert all 9 fields match using `assertStrField`/`assertIntField`.
|
||||||
|
|
||||||
|
**TestWriteWavTags_CoverArt** (covers WAV-04):
|
||||||
|
- Create bare WAV, write cover art via `FieldCoverArt: tinyJPEG(t)`.
|
||||||
|
- Read back, assert Picture is non-nil, `bytes.Equal` on data, MIME is "image/jpeg".
|
||||||
|
|
||||||
|
**TestWriteWavTags_ClearCoverArt** (covers WAV-04):
|
||||||
|
- Create WAV with cover art embedded via `createTestWAV(t, dir, "clear.wav", TagChanges{FieldCoverArt: art})`.
|
||||||
|
- Verify art present.
|
||||||
|
- Write with `TagChanges{FieldCoverArt: nil}` to clear.
|
||||||
|
- Read back, assert Picture is nil.
|
||||||
|
|
||||||
|
**TestWriteWavTags_PartialUpdate** (covers WAV-01):
|
||||||
|
- Create WAV with all fields populated via createTestWAV.
|
||||||
|
- Write only Title and Artist changes.
|
||||||
|
- Read back, assert Title and Artist changed, all other 7 fields preserved.
|
||||||
|
|
||||||
|
**TestWriteWavTags_ChunkPreservation** (covers WAV-02, WAV-03):
|
||||||
|
- Create WAV with extra chunks via `createTestWAVWithExtraChunks`.
|
||||||
|
- Read original file bytes.
|
||||||
|
- Write tags (Title only).
|
||||||
|
- Re-read file, parse RIFF chunks.
|
||||||
|
- Assert: fmt chunk data is byte-identical to original; data chunk data is byte-identical to original (WAV-03: audio data preserved); LIST chunk still present with original data; bext chunk still present with original data.
|
||||||
|
- Count total non-id3 chunks: should match original count.
|
||||||
|
|
||||||
|
**TestWriteWavTags_AtomicSafety** (covers WAV-05):
|
||||||
|
- Create WAV with initial tags.
|
||||||
|
- Read original file bytes.
|
||||||
|
- Attempt `writeWavTags` to a non-existent directory path (forces AtomicWrite to fail on temp file creation).
|
||||||
|
- Assert error returned.
|
||||||
|
- Read file bytes, assert identical to original (no modification on failure).
|
||||||
|
|
||||||
|
**TestWriteWavTags_RejectsRF64** (covers WAV-02 edge case):
|
||||||
|
- Create a file that starts with `RF64` + 4 size bytes + `WAVE` + minimal chunks.
|
||||||
|
- Attempt `writeWavTags`.
|
||||||
|
- Assert error contains "RF64".
|
||||||
|
|
||||||
|
**Linting requirements:**
|
||||||
|
- `t.Parallel()` at both suite and subtest level where possible (no shared state between tests since each uses t.TempDir()).
|
||||||
|
- `t.Helper()` in all helper functions.
|
||||||
|
- `t.Fatalf` for setup failures, `t.Errorf` for assertion failures.
|
||||||
|
- `//nolint:mnd` on magic numbers in fixture construction.
|
||||||
|
- Lines under 100 chars.
|
||||||
|
- Blank lines after early returns.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>go test -tags webkit2_41 -run TestWriteWavTags -v ./backend/tagwriter/ -count=1</automated>
|
||||||
|
</verify>
|
||||||
|
<done>All 7 WAV test functions pass; text fields round-trip, cover art round-trip, clear cover art, partial update, chunk preservation, atomic safety, and RF64 rejection all verified</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Full test suite verification</name>
|
||||||
|
<files></files>
|
||||||
|
<action>
|
||||||
|
Run the full project test suite to verify no regressions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
make test
|
||||||
|
```
|
||||||
|
|
||||||
|
This runs `go test -tags webkit2_41 -race -count=1 -timeout 120s ./...` which covers:
|
||||||
|
- All existing MP3 tag writer tests
|
||||||
|
- All existing FLAC tag writer tests
|
||||||
|
- All new WAV tag writer tests
|
||||||
|
- Pipeline tests
|
||||||
|
- Metadata tests
|
||||||
|
- All other package tests
|
||||||
|
|
||||||
|
Also run the lint check:
|
||||||
|
```bash
|
||||||
|
make lint
|
||||||
|
```
|
||||||
|
|
||||||
|
If any test failures or lint warnings exist:
|
||||||
|
- Fix WAV-specific issues in wav.go or wav_test.go.
|
||||||
|
- If existing test failures are unrelated to WAV changes, note them but don't modify unrelated code.
|
||||||
|
|
||||||
|
If lint warnings in wav.go or wav_test.go:
|
||||||
|
- Fix them (golines, nlreturn, wsl, godot, err113, etc.).
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>make test && make lint</automated>
|
||||||
|
</verify>
|
||||||
|
<done>make test passes with 0 failures; make lint passes with 0 warnings in tagwriter package; WAV-06 requirement (round-trip tests) is fully satisfied</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `go test -tags webkit2_41 -run TestWriteWavTags -v ./backend/tagwriter/ -count=1` — all 7 WAV tests pass
|
||||||
|
- `go test -tags webkit2_41 -run TestWriteMp3Tags ./backend/tagwriter/ -count=1` — MP3 tests still pass (no regression)
|
||||||
|
- `go test -tags webkit2_41 -run TestWriteFlacTags ./backend/tagwriter/ -count=1` — FLAC tests still pass (no regression)
|
||||||
|
- `make test` — full test suite passes
|
||||||
|
- `make lint` — no lint warnings in tagwriter package
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- All 8 text fields round-trip correctly (Title, Artist, Album, AlbumArtist, Genre, Year, TrackNumber, DiscNumber, Composer)
|
||||||
|
- Cover art embed/replace/clear works
|
||||||
|
- Partial updates preserve unchanged fields
|
||||||
|
- Non-ID3v2 RIFF chunks preserved byte-for-byte
|
||||||
|
- Audio data (data chunk) preserved byte-for-byte
|
||||||
|
- Atomic write leaves original untouched on failure
|
||||||
|
- RF64 rejected with clear error
|
||||||
|
- Full test suite (make test) passes with zero failures
|
||||||
|
- Lint (make lint) passes with zero warnings
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/19-wav-tag-writer/19-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
Reference in New Issue
Block a user