From 21fe17212d232b87a1c5285c9ce2d824a7bd6e79 Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Thu, 19 Mar 2026 08:50:20 -0400 Subject: [PATCH] test(19-02): add WAV test fixture builder and read-back helper - createTestWAV: builds minimal valid WAV with optional ID3v2 tag - readWavID3Tags: extracts ID3v2 from RIFF via parseRIFF + dhowden/tag - createTestWAVWithExtraChunks: adds LIST INFO and bext chunks for preservation tests - makePCMFmtData: generates 16-byte PCM format data --- backend/tagwriter/wav_test.go | 289 ++++++++++++++++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 backend/tagwriter/wav_test.go diff --git a/backend/tagwriter/wav_test.go b/backend/tagwriter/wav_test.go new file mode 100644 index 0000000..9529416 --- /dev/null +++ b/backend/tagwriter/wav_test.go @@ -0,0 +1,289 @@ +package tagwriter + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "testing" + + id3v2 "github.com/bogem/id3v2/v2" + "github.com/dhowden/tag" + + "yellowjacket/backend/metadata" +) + +// createTestWAV builds a minimal valid WAV file with an optional +// ID3v2 tag populated from fields. It returns the path to the +// created file inside dir. +func createTestWAV( + t *testing.T, + dir string, + name string, + fields TagChanges, +) string { + t.Helper() + + var buf bytes.Buffer + + // --- fmt chunk (24 bytes total: 8 header + 16 data) --- + fmtData := makePCMFmtData() + + // --- data chunk: 200 bytes of silence --- + const silenceLen = 200 //nolint:mnd + silence := make([]byte, silenceLen) + + // --- optional id3 chunk --- + var id3Bytes []byte + + if len(fields) > 0 { + id3Tag := id3v2.NewEmptyTag() + id3Tag.SetDefaultEncoding(id3v2.EncodingUTF8) + applyTextChanges(id3Tag, fields) + applyCoverArtChanges(id3Tag, fields) + + var id3Buf bytes.Buffer + if _, err := id3Tag.WriteTo(&id3Buf); err != nil { + t.Fatalf("write id3v2 tag: %v", err) + } + + id3Bytes = id3Buf.Bytes() + } + + // Calculate RIFF payload size: + // 4 (WAVE) + // + 8 + 16 (fmt chunk) + // + 8 + silenceLen (data chunk) + // + optional id3 chunk: 8 + len(id3Bytes) + padding + riffPayload := uint32(4 + 8 + 16 + 8 + silenceLen) //nolint:mnd + + if len(id3Bytes) > 0 { + riffPayload += 8 + uint32(len(id3Bytes)) //nolint:mnd + + if len(id3Bytes)%2 != 0 { + riffPayload++ + } + } + + // RIFF header. + buf.WriteString("RIFF") + _ = binary.Write(&buf, binary.LittleEndian, riffPayload) + buf.WriteString("WAVE") + + // fmt chunk. + buf.WriteString("fmt ") + _ = binary.Write( + &buf, binary.LittleEndian, uint32(len(fmtData)), + ) + buf.Write(fmtData) + + // data chunk. + buf.WriteString("data") + _ = binary.Write( + &buf, binary.LittleEndian, uint32(silenceLen), + ) + buf.Write(silence) + + // id3 chunk (if any). + if len(id3Bytes) > 0 { + buf.WriteString("id3 ") + _ = binary.Write( + &buf, binary.LittleEndian, uint32(len(id3Bytes)), + ) + buf.Write(id3Bytes) + + if len(id3Bytes)%2 != 0 { + buf.WriteByte(0) + } + } + + path := filepath.Join(dir, name) + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write test WAV: %v", err) + } + + return path +} + +// makePCMFmtData returns 16 bytes of PCM format data: +// AudioFormat=1, Channels=1, SampleRate=44100, +// ByteRate=88200, BlockAlign=2, BitsPerSample=16. +func makePCMFmtData() []byte { + var buf bytes.Buffer + + _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) // PCM + _ = binary.Write(&buf, binary.LittleEndian, uint16(1)) // mono + _ = binary.Write(&buf, binary.LittleEndian, uint32(44100)) // sample rate + _ = binary.Write(&buf, binary.LittleEndian, uint32(88200)) // byte rate + _ = binary.Write(&buf, binary.LittleEndian, uint16(2)) // block align + _ = binary.Write(&buf, binary.LittleEndian, uint16(16)) // bits per sample + + return buf.Bytes() +} + +// readWavID3Tags extracts ID3v2 metadata from a WAV file by parsing +// the RIFF structure and reading the id3 chunk with dhowden/tag. +// This is necessary because dhowden/tag's ReadFrom does not support +// WAV files directly. +func readWavID3Tags( + t *testing.T, + path string, +) *metadata.TrackMetadata { + t.Helper() + + f, err := os.Open(path) + if err != nil { + t.Fatalf("open WAV for read-back: %v", err) + } + + defer func() { _ = f.Close() }() + + chunks, err := parseRIFF(f) + if err != nil { + t.Fatalf("parseRIFF: %v", err) + } + + // Find the id3 chunk. + var id3Data []byte + + for _, c := range chunks { + if isID3ChunkID(c.id) { + id3Data = c.data + + break + } + } + + if id3Data == nil { + t.Fatal("no id3 chunk found in WAV file") + } + + // dhowden/tag ReadID3v2Tags expects an io.ReadSeeker starting + // with the "ID3" magic bytes. + m, err := tag.ReadID3v2Tags(bytes.NewReader(id3Data)) + if err != nil { + t.Fatalf("ReadID3v2Tags: %v", err) + } + + trackNum, _ := m.Track() + discNum, _ := m.Disc() + + meta := &metadata.TrackMetadata{ + Title: m.Title(), + Artist: m.Artist(), + Album: m.Album(), + AlbumArtist: m.AlbumArtist(), + Composer: m.Composer(), + Genre: m.Genre(), + Year: m.Year(), + TrackNumber: trackNum, + DiscNumber: discNum, + } + + if pic := m.Picture(); pic != nil { + meta.Picture = &metadata.PictureData{ + Data: pic.Data, + MIMEType: pic.MIMEType, + Ext: pic.Ext, + } + } + + return meta +} + +// createTestWAVWithExtraChunks creates a WAV file containing +// additional non-standard chunks (LIST INFO, bext) to verify that +// writeWavTags preserves chunks it does not understand. +func createTestWAVWithExtraChunks( + t *testing.T, + dir string, + name string, +) string { + t.Helper() + + var buf bytes.Buffer + + fmtData := makePCMFmtData() + + // LIST INFO chunk: LIST + size + "INFO" + INAM sub-chunk. + var listBuf bytes.Buffer + + listBuf.WriteString("INFO") + + // INAM sub-chunk. + inamValue := []byte("Test Track Name") + listBuf.WriteString("INAM") + _ = binary.Write( + &listBuf, binary.LittleEndian, uint32(len(inamValue)), + ) + listBuf.Write(inamValue) + + // Pad INAM if odd length. + if len(inamValue)%2 != 0 { + listBuf.WriteByte(0) + } + + listData := listBuf.Bytes() + + // Fake bext chunk: 8 bytes of test data. + bextData := []byte{0xBE, 0xEF, 0xCA, 0xFE, 0xDE, 0xAD, 0x01, 0x02} + + // 200 bytes of silence for data chunk. + const silenceLen = 200 //nolint:mnd + silence := make([]byte, silenceLen) + + // Calculate RIFF payload. + riffPayload := uint32(4) // WAVE + + // fmt: 8 + 16. + riffPayload += 8 + uint32(len(fmtData)) //nolint:mnd + + // LIST: 8 + len(listData). + riffPayload += 8 + uint32(len(listData)) //nolint:mnd + + // bext: 8 + len(bextData). + riffPayload += 8 + uint32(len(bextData)) //nolint:mnd + + // data: 8 + silenceLen. + riffPayload += 8 + silenceLen //nolint:mnd + + // RIFF header. + buf.WriteString("RIFF") + _ = binary.Write(&buf, binary.LittleEndian, riffPayload) + buf.WriteString("WAVE") + + // fmt chunk. + buf.WriteString("fmt ") + _ = binary.Write( + &buf, binary.LittleEndian, uint32(len(fmtData)), + ) + buf.Write(fmtData) + + // LIST chunk. + buf.WriteString("LIST") + _ = binary.Write( + &buf, binary.LittleEndian, uint32(len(listData)), + ) + buf.Write(listData) + + // bext chunk. + buf.WriteString("bext") + _ = binary.Write( + &buf, binary.LittleEndian, uint32(len(bextData)), + ) + buf.Write(bextData) + + // data chunk. + buf.WriteString("data") + _ = binary.Write( + &buf, binary.LittleEndian, uint32(silenceLen), + ) + buf.Write(silence) + + path := filepath.Join(dir, name) + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatalf("write test WAV with extra chunks: %v", err) + } + + return path +}