From 84dd371fddc1ef7cd3b98feb1304d02479e3a20a Mon Sep 17 00:00:00 2001 From: Mathis Maquenne <124215603+mathismqn@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:51:43 +0200 Subject: [PATCH] feat: tag wav files with id3 and info chunks --- internal/tag/id3v2.go | 64 +++++----- internal/tag/tag.go | 5 +- internal/tag/wav.go | 256 +++++++++++++++++++++++++++++++++++++++ internal/tag/wav_test.go | 246 +++++++++++++++++++++++++++++++++++++ 4 files changed, 540 insertions(+), 31 deletions(-) create mode 100644 internal/tag/wav.go create mode 100644 internal/tag/wav_test.go diff --git a/internal/tag/id3v2.go b/internal/tag/id3v2.go index 0ef34c0..8c5c907 100644 --- a/internal/tag/id3v2.go +++ b/internal/tag/id3v2.go @@ -14,68 +14,72 @@ type id3v2Tagger struct { func (t *id3v2Tagger) write(m Metadata) error { defer t.tag.Close() + applyID3Frames(t.tag, m) + + return t.tag.Save() +} + +func applyID3Frames(tag *id3v2.Tag, m Metadata) { if m.Album != nil { year := m.Album.ReleaseDate if parts := strings.Split(year, "-"); len(parts) == 3 { year = parts[0] } - t.addTag("TRCK", m.TrackNumber) - t.addTag("TPE2", m.Album.Artist) - t.addTag("TALB", m.Album.Title) - t.addTag("TPUB", m.Album.Label) - t.addTag("TDOR", m.Album.OriginalReleaseDate) - t.addTag("TYER", year) - t.addComment(m.Album.ProducerLine) - t.addTag("TCOP", m.Album.Copyright) + addID3Text(tag, "TRCK", m.TrackNumber) + addID3Text(tag, "TPE2", m.Album.Artist) + addID3Text(tag, "TALB", m.Album.Title) + addID3Text(tag, "TPUB", m.Album.Label) + addID3Text(tag, "TDOR", m.Album.OriginalReleaseDate) + addID3Text(tag, "TYER", year) + addID3Comment(tag, m.Album.ProducerLine) + addID3Text(tag, "TCOP", m.Album.Copyright) } - t.addTag("TPE1", m.Artists) - t.addTag("TIT2", m.Title) - t.addTag("TCOM", m.Composers) - t.addTag("TEXT", m.Lyricists) - t.addTag("TCON", m.Genre) + addID3Text(tag, "TPE1", m.Artists) + addID3Text(tag, "TIT2", m.Title) + addID3Text(tag, "TCOM", m.Composers) + addID3Text(tag, "TEXT", m.Lyricists) + addID3Text(tag, "TCON", m.Genre) if duration, err := strconv.Atoi(m.Duration); err == nil { - t.addTag("TLEN", strconv.Itoa(duration*1000)) + addID3Text(tag, "TLEN", strconv.Itoa(duration*1000)) } - t.addTag("TBPM", m.BPM) - t.addTag("TKEY", m.Key) - t.addTXXX("GAIN", m.Gain) - t.addTXXX("ISRC", m.ISRC) + addID3Text(tag, "TBPM", m.BPM) + addID3Text(tag, "TKEY", m.Key) + addID3TXXX(tag, "GAIN", m.Gain) + addID3TXXX(tag, "ISRC", m.ISRC) if len(m.Cover) > 0 { - t.tag.AddAttachedPicture(id3v2.PictureFrame{ - Encoding: t.tag.DefaultEncoding(), + tag.AddAttachedPicture(id3v2.PictureFrame{ + Encoding: tag.DefaultEncoding(), MimeType: "image/jpeg", PictureType: id3v2.PTFrontCover, Description: "Cover", Picture: m.Cover, }) } - - return t.tag.Save() } -func (t *id3v2Tagger) addTag(name, value string) { +func addID3Text(tag *id3v2.Tag, name, value string) { if value != "" { - t.tag.AddTextFrame(name, t.tag.DefaultEncoding(), value) + tag.AddTextFrame(name, tag.DefaultEncoding(), value) } } -func (t *id3v2Tagger) addComment(value string) { +func addID3Comment(tag *id3v2.Tag, value string) { if value != "" { - t.tag.AddCommentFrame(id3v2.CommentFrame{ - Encoding: t.tag.DefaultEncoding(), + tag.AddCommentFrame(id3v2.CommentFrame{ + Encoding: tag.DefaultEncoding(), Language: "eng", Text: value, }) } } -func (t *id3v2Tagger) addTXXX(description, value string) { +func addID3TXXX(tag *id3v2.Tag, description, value string) { if value != "" { - t.tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ - Encoding: t.tag.DefaultEncoding(), + tag.AddUserDefinedTextFrame(id3v2.UserDefinedTextFrame{ + Encoding: tag.DefaultEncoding(), Description: description, Value: value, }) diff --git a/internal/tag/tag.go b/internal/tag/tag.go index bb3bd31..6b3ed40 100644 --- a/internal/tag/tag.go +++ b/internal/tag/tag.go @@ -39,12 +39,15 @@ type tagger interface { } func newTagger(filePath string) (tagger, error) { - if filepath.Ext(filePath) == ".mp3" { + switch filepath.Ext(filePath) { + case ".mp3": tag, err := id3v2.Open(filePath, id3v2.Options{Parse: true}) if err != nil { return nil, err } return &id3v2Tagger{tag: tag}, nil + case ".wav": + return &wavTagger{path: filePath}, nil } file, err := flac.ParseFile(filePath) diff --git a/internal/tag/wav.go b/internal/tag/wav.go new file mode 100644 index 0000000..2cb5243 --- /dev/null +++ b/internal/tag/wav.go @@ -0,0 +1,256 @@ +package tag + +import ( + "bytes" + "encoding/binary" + "errors" + "io" + "os" + "strings" + + "github.com/bogem/id3v2/v2" +) + +type wavTagger struct { + path string +} + +type wavChunk struct { + id string + payload []byte +} + +type infoField struct { + id string + value string +} + +func (t *wavTagger) write(m Metadata) error { + id3Chunk, err := buildID3Chunk(m) + if err != nil { + return err + } + + var chunks []wavChunk + if info := buildInfoChunk(m); info != nil { + chunks = append(chunks, wavChunk{id: "LIST", payload: info}) + } + if id3Chunk != nil { + chunks = append(chunks, wavChunk{id: "id3 ", payload: id3Chunk}) + } + + return rewriteWAV(t.path, chunks) +} + +func buildID3Chunk(m Metadata) ([]byte, error) { + tag := id3v2.NewEmptyTag() + applyID3Frames(tag, m) + if !tag.HasFrames() { + return nil, nil + } + + var buf bytes.Buffer + if _, err := tag.WriteTo(&buf); err != nil { + return nil, err + } + + return buf.Bytes(), nil +} + +func buildInfoChunk(m Metadata) []byte { + fields := []infoField{ + {"INAM", m.Title}, + {"IART", m.Artists}, + {"IGNR", m.Genre}, + {"ITRK", m.TrackNumber}, + } + + if m.Album != nil { + date := m.Album.ReleaseDate + if parts := strings.Split(date, "-"); len(parts) == 3 { + date = parts[0] + } + + fields = append(fields, + infoField{"IPRD", m.Album.Title}, + infoField{"ICRD", date}, + infoField{"ICMT", m.Album.ProducerLine}, + infoField{"ICOP", m.Album.Copyright}, + ) + } + + var buf bytes.Buffer + buf.WriteString("INFO") + + for _, field := range fields { + if field.value == "" { + continue + } + writeChunk(&buf, field.id, append([]byte(field.value), 0)) + } + + if buf.Len() == 4 { + return nil + } + + return buf.Bytes() +} + +func writeChunk(w io.Writer, id string, payload []byte) { + header := make([]byte, 0, 8) + header = append(header, id...) + header = binary.LittleEndian.AppendUint32(header, uint32(len(payload))) + + w.Write(header) + w.Write(payload) + if len(payload)%2 != 0 { + w.Write([]byte{0}) + } +} + +func rewriteWAV(path string, chunks []wavChunk) error { + src, err := os.Open(path) + if err != nil { + return err + } + defer src.Close() + + header := make([]byte, 12) + if _, err := io.ReadFull(src, header); err != nil { + return err + } + if string(header[0:4]) != "RIFF" || string(header[8:12]) != "WAVE" { + return errors.New("not a wav file") + } + + tmpPath := path + ".tmp" + dst, err := os.Create(tmpPath) + if err != nil { + return err + } + done := false + defer func() { + if !done { + dst.Close() + os.Remove(tmpPath) + } + }() + + if _, err := dst.Write(header); err != nil { + return err + } + + size, err := copyChunks(dst, src) + if err != nil { + return err + } + + for _, chunk := range chunks { + var buf bytes.Buffer + writeChunk(&buf, chunk.id, chunk.payload) + if _, err := dst.Write(buf.Bytes()); err != nil { + return err + } + size += int64(buf.Len()) + } + + riffSize := make([]byte, 4) + binary.LittleEndian.PutUint32(riffSize, uint32(size)) + if _, err := dst.WriteAt(riffSize, 4); err != nil { + return err + } + + if err := dst.Sync(); err != nil { + return err + } + if err := dst.Close(); err != nil { + return err + } + if err := os.Rename(tmpPath, path); err != nil { + return err + } + done = true + + return nil +} + +func copyChunks(dst io.Writer, src io.Reader) (int64, error) { + size := int64(4) + head := make([]byte, 8) + + for { + if _, err := io.ReadFull(src, head); err != nil { + if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) { + return size, nil + } + return size, err + } + + id := string(head[0:4]) + payloadSize := int64(binary.LittleEndian.Uint32(head[4:8])) + + if id == "id3 " || id == "ID3 " { + if err := skipPayload(src, payloadSize); err != nil { + return size, err + } + continue + } + + if id == "LIST" { + payload := make([]byte, payloadSize) + if _, err := io.ReadFull(src, payload); err != nil { + return size, err + } + if err := skipPad(src, payloadSize); err != nil { + return size, err + } + if bytes.HasPrefix(payload, []byte("INFO")) { + continue + } + + var buf bytes.Buffer + writeChunk(&buf, id, payload) + if _, err := dst.Write(buf.Bytes()); err != nil { + return size, err + } + size += int64(buf.Len()) + + continue + } + + if _, err := dst.Write(head); err != nil { + return size, err + } + if _, err := io.CopyN(dst, src, payloadSize); err != nil { + return size, err + } + size += 8 + payloadSize + + if payloadSize%2 != 0 { + if _, err := dst.Write([]byte{0}); err != nil { + return size, err + } + size++ + if err := skipPad(src, payloadSize); err != nil { + return size, err + } + } + } +} + +func skipPayload(src io.Reader, payloadSize int64) error { + if _, err := io.CopyN(io.Discard, src, payloadSize); err != nil { + return err + } + return skipPad(src, payloadSize) +} + +func skipPad(src io.Reader, payloadSize int64) error { + if payloadSize%2 == 0 { + return nil + } + if _, err := io.CopyN(io.Discard, src, 1); err != nil && !errors.Is(err, io.EOF) { + return err + } + return nil +} diff --git a/internal/tag/wav_test.go b/internal/tag/wav_test.go new file mode 100644 index 0000000..2d1cca5 --- /dev/null +++ b/internal/tag/wav_test.go @@ -0,0 +1,246 @@ +package tag + +import ( + "bytes" + "encoding/binary" + "os" + "path/filepath" + "testing" +) + +func minimalWAV(audio []byte) []byte { + var body bytes.Buffer + body.WriteString("WAVE") + + fmtPayload := make([]byte, 0, 16) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 1) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 2) + fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 44100) + fmtPayload = binary.LittleEndian.AppendUint32(fmtPayload, 176400) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 4) + fmtPayload = binary.LittleEndian.AppendUint16(fmtPayload, 16) + writeChunk(&body, "fmt ", fmtPayload) + writeChunk(&body, "data", audio) + + var out bytes.Buffer + out.WriteString("RIFF") + binary.Write(&out, binary.LittleEndian, uint32(body.Len())) + out.Write(body.Bytes()) + + return out.Bytes() +} + +func parseChunks(t *testing.T, data []byte) map[string][]byte { + t.Helper() + + if string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" { + t.Fatalf("bad riff header: %q %q", data[0:4], data[8:12]) + } + if size := binary.LittleEndian.Uint32(data[4:8]); int(size) != len(data)-8 { + t.Errorf("riff size = %d, want %d", size, len(data)-8) + } + + chunks := make(map[string][]byte) + for offset := 12; offset < len(data); { + if offset%2 != 0 { + t.Errorf("chunk at offset %d is not word aligned", offset) + } + if offset+8 > len(data) { + t.Fatalf("truncated chunk header at offset %d", offset) + } + + id := string(data[offset : offset+4]) + size := int(binary.LittleEndian.Uint32(data[offset+4 : offset+8])) + if offset+8+size > len(data) { + t.Fatalf("chunk %q at offset %d overruns the file", id, offset) + } + if _, ok := chunks[id]; ok { + t.Errorf("duplicate %q chunk", id) + } + chunks[id] = data[offset+8 : offset+8+size] + + offset += 8 + size + size%2 + } + + return chunks +} + +func testMetadata() Metadata { + return Metadata{ + Title: "Song", + Artists: "Artist", + Genre: "Rock", + BPM: "120", + Key: "Am", + TrackNumber: "3", + Duration: "215", + Gain: "-7.5", + ISRC: "FR1234567890", + Cover: []byte{0xff, 0xd8, 0xff, 0xe0, 0x00}, + Album: &AlbumMetadata{ + Artist: "Album Artist", + Title: "Album", + Label: "Label", + ReleaseDate: "2024-05-01", + ProducerLine: "Producer line", + Copyright: "Copyright", + }, + } +} + +func writeTestWAV(t *testing.T, audio []byte) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "track.wav") + if err := os.WriteFile(path, minimalWAV(audio), 0644); err != nil { + t.Fatalf("write wav: %v", err) + } + + return path +} + +func TestWriteWAV(t *testing.T) { + audio := bytes.Repeat([]byte{0x11, 0x22, 0x33, 0x44}, 16) + path := writeTestWAV(t, audio) + original := parseChunks(t, minimalWAV(audio)) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("Write() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + chunks := parseChunks(t, data) + + if !bytes.Equal(chunks["data"], original["data"]) { + t.Error("data chunk was modified") + } + if !bytes.Equal(chunks["fmt "], original["fmt "]) { + t.Error("fmt chunk was modified") + } + + id3, ok := chunks["id3 "] + if !ok { + t.Fatal("missing id3 chunk") + } + if string(id3[0:3]) != "ID3" { + t.Errorf("id3 chunk does not start with an ID3 header: %q", id3[0:3]) + } + for _, want := range []string{"Song", "Artist", "Album", "120", "Am", "FR1234567890", "Rock"} { + if !bytes.Contains(id3, []byte(want)) { + t.Errorf("id3 chunk is missing %q", want) + } + } + if !bytes.Contains(id3, []byte{0xff, 0xd8, 0xff, 0xe0}) { + t.Error("id3 chunk is missing the cover art") + } + + list, ok := chunks["LIST"] + if !ok { + t.Fatal("missing LIST chunk") + } + if string(list[0:4]) != "INFO" { + t.Errorf("LIST form = %q, want \"INFO\"", list[0:4]) + } + for _, want := range []struct{ id, value string }{ + {"INAM", "Song"}, + {"IART", "Artist"}, + {"IGNR", "Rock"}, + {"ITRK", "3"}, + {"IPRD", "Album"}, + {"ICRD", "2024"}, + {"ICMT", "Producer line"}, + {"ICOP", "Copyright"}, + } { + if !bytes.Contains(list, append([]byte(want.id), append([]byte{byte(len(want.value) + 1), 0, 0, 0}, want.value...)...)) { + t.Errorf("LIST chunk is missing %s = %q", want.id, want.value) + } + } +} + +func TestWriteWAVIsIdempotent(t *testing.T) { + audio := bytes.Repeat([]byte{0x01, 0x02}, 32) + path := writeTestWAV(t, audio) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("first Write() error = %v", err) + } + first, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + firstChunks := parseChunks(t, first) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("second Write() error = %v", err) + } + second, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + secondChunks := parseChunks(t, second) + + if len(first) != len(second) { + t.Errorf("re-tagging changed the file size: %d bytes then %d bytes", len(first), len(second)) + } + if len(firstChunks) != len(secondChunks) { + t.Errorf("chunk count = %d, want %d", len(secondChunks), len(firstChunks)) + } + for id, payload := range firstChunks { + got, ok := secondChunks[id] + if !ok { + t.Errorf("re-tagging dropped the %q chunk", id) + continue + } + if len(got) != len(payload) { + t.Errorf("%q chunk size = %d, want %d", id, len(got), len(payload)) + } + } + if !bytes.Equal(secondChunks["data"], audio) { + t.Error("data chunk was modified") + } + if !bytes.Equal(secondChunks["fmt "], firstChunks["fmt "]) { + t.Error("fmt chunk was modified") + } + if !bytes.Equal(secondChunks["LIST"], firstChunks["LIST"]) { + t.Error("LIST chunk was modified") + } +} + +func TestWriteWAVOddSizedChunks(t *testing.T) { + audio := bytes.Repeat([]byte{0x07}, 33) + path := writeTestWAV(t, audio) + + if err := Write(path, testMetadata()); err != nil { + t.Fatalf("Write() error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read wav: %v", err) + } + + chunks := parseChunks(t, data) + if !bytes.Equal(chunks["data"], audio) { + t.Error("data chunk was modified") + } + if _, ok := chunks["id3 "]; !ok { + t.Error("missing id3 chunk") + } +} + +func TestWriteWAVRejectsNonWAV(t *testing.T) { + path := filepath.Join(t.TempDir(), "track.wav") + if err := os.WriteFile(path, []byte("this is not a wav file at all"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + if err := Write(path, testMetadata()); err == nil { + t.Error("Write() error = nil, want error") + } + if _, err := os.Stat(path + ".tmp"); err == nil { + t.Error("Write() left a temp file behind") + } +}