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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user