feat(19-01): implement WAV RIFF parser/writer and writeWavTags

- Add FormatWAV constant and .wav DetectFormat case
- Add FormatWAV dispatch in pipeline WriteTrackTags switch
- Create wav.go with custom RIFF chunk parser/writer
- parseRIFF: lenient read, RF64 rejection, case-insensitive ID3 chunk detection
- writeRIFF: strict write with correct padding and 4GB size check
- writeWavTags: merge existing ID3v2, reuse applyTextChanges/applyCoverArtChanges
- Atomic write via fileutil.AtomicWrite for crash safety
This commit is contained in:
2026-03-19 08:44:38 -04:00
parent 8f4c4a0c2b
commit e6610ff15e
3 changed files with 287 additions and 0 deletions
+2
View File
@@ -144,6 +144,8 @@ func (tw *TagWriter) WriteTrackTags(trackID int64, changes TagChanges) error {
err = writeMp3Tags(tw.logger, audioFile.FilePath, changes)
case FormatFLAC:
err = writeFlacTags(tw.logger, audioFile.FilePath, changes)
case FormatWAV:
err = writeWavTags(tw.logger, audioFile.FilePath, changes)
default:
err = fmt.Errorf("%w: %s", errUnsupportedFormat, format)
}
+4
View File
@@ -36,6 +36,8 @@ const (
FormatMP3 AudioFormat = "mp3"
// FormatFLAC is the FLAC audio format.
FormatFLAC AudioFormat = "flac"
// FormatWAV is the WAV audio format.
FormatWAV AudioFormat = "wav"
)
// errUnsupportedFormat is returned when the audio format is not supported.
@@ -50,6 +52,8 @@ func DetectFormat(filePath string) (AudioFormat, error) {
return FormatMP3, nil
case ".flac":
return FormatFLAC, nil
case ".wav":
return FormatWAV, nil
default:
return "", fmt.Errorf("%w: %s", errUnsupportedFormat, ext)
}
+281
View File
@@ -0,0 +1,281 @@
package tagwriter
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"io"
"log/slog"
"os"
"strings"
id3v2 "github.com/bogem/id3v2/v2"
"yellowjacket/backend/fileutil"
)
// Sentinel errors for WAV RIFF operations.
var (
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)")
)
// riffChunk holds a single RIFF sub-chunk (ID + raw data).
type riffChunk struct {
id [4]byte
data []byte
}
// parseRIFF reads all RIFF sub-chunks from r. It rejects RF64 files
// and non-WAVE containers with descriptive errors. The parser is
// lenient on read: it tolerates missing padding bytes and ignores
// the declared RIFF size.
func parseRIFF(r io.ReadSeeker) ([]riffChunk, error) {
// Read 4-byte container magic.
var magic [4]byte
if _, err := io.ReadFull(r, magic[:]); err != nil {
return nil, fmt.Errorf("read RIFF magic: %w", err)
}
if string(magic[:]) == "RF64" {
return nil, errRF64NotSupported
}
if string(magic[:]) != "RIFF" {
return nil, fmt.Errorf("%w: got %q", errNotRIFF, magic)
}
// Read (and discard) RIFF size — lenient, do not enforce.
var riffSize uint32
if err := binary.Read(r, binary.LittleEndian, &riffSize); err != nil {
return nil, fmt.Errorf("read RIFF size: %w", err)
}
// Read 4-byte form type.
var form [4]byte
if _, err := io.ReadFull(r, form[:]); err != nil {
return nil, fmt.Errorf("read WAVE form type: %w", err)
}
if string(form[:]) != "WAVE" {
return nil, fmt.Errorf("%w: got %q", errNotWAVE, form)
}
// Read sub-chunks until EOF.
var chunks []riffChunk
for {
var chunkID [4]byte
_, err := io.ReadFull(r, chunkID[:])
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
break
}
if err != nil {
return nil, fmt.Errorf("read chunk ID: %w", err)
}
var chunkSize uint32
if err := binary.Read(r, binary.LittleEndian, &chunkSize); err != nil {
return nil, fmt.Errorf("read chunk size for %q: %w", chunkID, err)
}
data := make([]byte, chunkSize)
if _, err := io.ReadFull(r, data); err != nil {
return nil, fmt.Errorf("read chunk data for %q: %w", chunkID, err)
}
chunks = append(chunks, riffChunk{id: chunkID, data: data})
// Odd-length chunks have a padding byte. Lenient: if the
// read fails (e.g. EOF), just break rather than error.
if chunkSize%2 != 0 {
var pad [1]byte
if _, err := r.Read(pad[:]); err != nil {
break
}
}
}
return chunks, nil
}
// isID3ChunkID returns true if id represents an ID3v2 RIFF chunk.
// Both lowercase "id3 " and uppercase "ID3 " are accepted.
func isID3ChunkID(id [4]byte) bool {
s := strings.ToLower(string(id[:3]))
return s == "id3"
}
// writeRIFF writes a complete RIFF/WAVE container to w, preserving
// the given chunks in order and appending the id3Data as the final
// "id3 " chunk. Returns errFileTooLargeForWAV if the result would
// exceed the 4 GB RIFF limit.
func writeRIFF(w io.Writer, chunks []riffChunk, id3Data []byte) error {
// Calculate total RIFF payload size:
// 4 bytes (WAVE form type)
// + for each preserved chunk: 8 (header) + len(data) + padding
// + id3 chunk: 8 + len(id3Data) + padding
riffPayload := uint64(4)
for _, c := range chunks {
sz := uint64(len(c.data))
riffPayload += 8 + sz
if sz%2 != 0 {
riffPayload++
}
}
id3Len := uint64(len(id3Data))
riffPayload += 8 + id3Len
if id3Len%2 != 0 {
riffPayload++
}
// The RIFF header itself is 8 bytes (magic + size), so the
// total file size is riffPayload + 8.
const maxRIFFSize = 0xFFFFFFFF
if riffPayload > maxRIFFSize {
return errFileTooLargeForWAV
}
// Write RIFF header: magic + uint32 LE size + WAVE.
if _, err := w.Write([]byte("RIFF")); err != nil {
return fmt.Errorf("write RIFF magic: %w", err)
}
if err := binary.Write(w, binary.LittleEndian, uint32(riffPayload)); err != nil {
return fmt.Errorf("write RIFF size: %w", err)
}
if _, err := w.Write([]byte("WAVE")); err != nil {
return fmt.Errorf("write WAVE form: %w", err)
}
// Write each preserved chunk.
for _, c := range chunks {
if err := writeChunk(w, c.id, c.data); err != nil {
return err
}
}
// Write id3 chunk last.
var id3ID [4]byte
copy(id3ID[:], "id3 ")
return writeChunk(w, id3ID, id3Data)
}
// writeChunk writes a single RIFF sub-chunk (id + size + data + padding).
func writeChunk(w io.Writer, id [4]byte, data []byte) error {
if _, err := w.Write(id[:]); err != nil {
return fmt.Errorf("write chunk ID %q: %w", id, err)
}
if err := binary.Write(w, binary.LittleEndian, uint32(len(data))); err != nil {
return fmt.Errorf("write chunk size %q: %w", id, err)
}
if _, err := w.Write(data); err != nil {
return fmt.Errorf("write chunk data %q: %w", id, err)
}
// Pad odd-length chunks with a zero byte.
if len(data)%2 != 0 {
if _, err := w.Write([]byte{0}); err != nil {
return fmt.Errorf("write chunk padding %q: %w", id, err)
}
}
return nil
}
// writeWavTags applies the given TagChanges to a WAV file's ID3v2 tag
// embedded in a RIFF "id3 " chunk. All non-ID3v2 chunks are
// preserved byte-for-byte in their original order. The result is
// written atomically via fileutil.AtomicWrite.
func writeWavTags(
logger *slog.Logger,
filePath string,
changes TagChanges,
) error {
// Warn for very large files (same threshold as FLAC writer).
if info, err := os.Stat(filePath); err == nil {
const largeSizeThreshold = 500 * 1024 * 1024 // 500 MB
if info.Size() > largeSizeThreshold {
logger.Warn("large WAV file may use significant memory",
slog.String("path", filePath),
slog.Int64("size", info.Size()),
)
}
}
f, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open wav for reading: %w", err)
}
allChunks, err := parseRIFF(f)
// Close immediately — we need the handle released before
// AtomicWrite creates the replacement file.
_ = f.Close()
if err != nil {
return fmt.Errorf("parse wav RIFF: %w", err)
}
// Separate preserved chunks from existing ID3 data.
var (
preserved []riffChunk
existingID3 []byte
)
for _, c := range allChunks {
if isID3ChunkID(c.id) {
existingID3 = c.data
} else {
preserved = append(preserved, c)
}
}
// Build ID3v2 tag — merge with existing if present.
var tag *id3v2.Tag
if len(existingID3) > 0 {
parsed, parseErr := id3v2.ParseReader(
bytes.NewReader(existingID3),
id3v2.Options{Parse: true},
)
if parseErr != nil {
return fmt.Errorf("parse existing ID3v2 in WAV: %w", parseErr)
}
tag = parsed
} else {
tag = id3v2.NewEmptyTag()
tag.SetDefaultEncoding(id3v2.EncodingUTF8)
}
applyTextChanges(tag, changes)
applyCoverArtChanges(tag, changes)
// Serialize tag to bytes.
var id3Buf bytes.Buffer
if _, err := tag.WriteTo(&id3Buf); err != nil {
return fmt.Errorf("serialize ID3v2 tag: %w", err)
}
return fileutil.AtomicWrite(logger, filePath, func(tmp *os.File) error {
return writeRIFF(tmp, preserved, id3Buf.Bytes())
})
}