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
|
||||
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)
|
||||
**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
|
||||
**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 |
|
||||
| 17. Single Track 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 | - |
|
||||
| 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