tagwriter has always written a WAV's tags into a RIFF "id3 " chunk correctly, and dhowden/tag -- which metadata.ExtractTags is built on -- has no RIFF reader at all. So the app could not see tags it had just written: editing tags on a WAV, autotagging a WAV folder or importing a WAV download all appeared to succeed and changed nothing the library could show, while the file on disk really was tagged and other players read it. backend/riff is a new package rather than a move into either half, because tagwriter already imports metadata: reaching back for parseRIFF is an import cycle, not merely the wrong direction. backend/tagtotals is the precedent. Its two readers are deliberately different. Parse holds every chunk in memory, which is what rewriting a file needs -- and a WAV's audio *is* a chunk, so doing that on the scan path would read every WAV in the library in full. ID3Chunk seeks over what it is not looking for. The container is asked before tag.ReadFrom rather than after it fails, because that library's last resort is an ID3v1 trailer and a WAV carrying both would otherwise be read by the wrong one. An untagged WAV -- no chunk, an RF64 container, a tag with every frame cleared -- reads as empty metadata with no TagReadWarning: the scanner's filename fallback is the right answer there, and a warning would report a fault on a healthy file. The gap was pinned by TestWAVTagsAreNotReadableYet, which failed the moment the reader learned and said in its own comment what to update. So it goes, TestFixturesMatchManifest no longer skips wav, and totals_test.go's WAV case reads through metadata.ExtractTags like the other three formats -- a round trip asserted through the writer's own parser was a test of the writer, which is why nothing caught this. Closes #104
184 lines
4.9 KiB
Go
184 lines
4.9 KiB
Go
// Package riff reads the chunk layout of a RIFF/WAVE container.
|
|
//
|
|
// It exists because both halves of WAV tagging need it and neither can
|
|
// import the other: backend/tagwriter writes a WAV's tags into a RIFF
|
|
// "id3 " chunk and already imports backend/metadata, which is what has
|
|
// to read them back out. backend/tagtotals is the precedent.
|
|
//
|
|
// The two readers here are deliberately different. Parse holds every
|
|
// chunk's data in memory, which is what rewriting a file needs; a WAV's
|
|
// audio *is* the "data" chunk, so doing that on the scan path would
|
|
// read every library file in full. ID3Chunk seeks over what it is not
|
|
// looking for instead. Both walk the same headers.
|
|
package riff
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
)
|
|
|
|
// Sentinel errors describing a container this package will not read.
|
|
var (
|
|
ErrRF64NotSupported = errors.New("RF64 files are not yet supported")
|
|
ErrNotRIFF = errors.New("not a RIFF file")
|
|
ErrNotWAVE = errors.New("not a WAVE file")
|
|
ErrNoID3Chunk = errors.New("no ID3 chunk in RIFF file")
|
|
)
|
|
|
|
// Chunk holds a single RIFF sub-chunk (ID + raw data).
|
|
type Chunk struct {
|
|
ID [4]byte
|
|
Data []byte
|
|
}
|
|
|
|
// IsID3 reports whether id is that of an ID3v2 RIFF chunk. Both
|
|
// lowercase "id3 " and uppercase "ID3 " are accepted.
|
|
func IsID3(id [4]byte) bool {
|
|
return strings.ToLower(string(id[:3])) == "id3"
|
|
}
|
|
|
|
// Parse reads every RIFF sub-chunk from r, in order, starting at the
|
|
// reader's current position. It rejects RF64 files and non-WAVE
|
|
// containers with descriptive errors. The parser is lenient: it
|
|
// tolerates a missing final padding byte and ignores the declared
|
|
// RIFF size.
|
|
func Parse(r io.Reader) ([]Chunk, error) {
|
|
if err := readContainer(r); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var chunks []Chunk
|
|
|
|
for {
|
|
id, size, err := nextHeader(r)
|
|
if errors.Is(err, io.EOF) {
|
|
break
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
data := make([]byte, size)
|
|
if _, err := io.ReadFull(r, data); err != nil {
|
|
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
|
|
}
|
|
|
|
chunks = append(chunks, Chunk{ID: id, Data: data})
|
|
|
|
// Odd-length chunks have a padding byte. Lenient: if the
|
|
// read fails (e.g. EOF), just break rather than error.
|
|
if size%2 != 0 {
|
|
var pad [1]byte
|
|
|
|
if _, err := r.Read(pad[:]); err != nil {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return chunks, nil
|
|
}
|
|
|
|
// ID3Chunk returns the payload of the ID3v2 chunk of the RIFF/WAVE
|
|
// container at the reader's current position, seeking over every other
|
|
// chunk rather than reading it. It returns ErrNoID3Chunk when the
|
|
// container carries no such chunk, and leaves the read position
|
|
// unspecified either way.
|
|
func ID3Chunk(r io.ReadSeeker) ([]byte, error) {
|
|
if err := readContainer(r); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for {
|
|
id, size, err := nextHeader(r)
|
|
if errors.Is(err, io.EOF) {
|
|
return nil, ErrNoID3Chunk
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !IsID3(id) {
|
|
// Odd-length chunks carry a padding byte. Seeking past
|
|
// the end of the file is not an error; the next header
|
|
// read is what reports the end.
|
|
if _, err := r.Seek(int64(size)+int64(size%2), io.SeekCurrent); err != nil {
|
|
return nil, fmt.Errorf("skip chunk %q: %w", id, err)
|
|
}
|
|
|
|
continue
|
|
}
|
|
|
|
// Copied rather than allocated up front: a truncated file is
|
|
// free to declare a chunk larger than the whole of itself.
|
|
var data bytes.Buffer
|
|
if _, err := io.CopyN(&data, r, int64(size)); err != nil {
|
|
return nil, fmt.Errorf("read chunk data for %q: %w", id, err)
|
|
}
|
|
|
|
return data.Bytes(), nil
|
|
}
|
|
}
|
|
|
|
// readContainer consumes the 12-byte RIFF/WAVE header at the reader's
|
|
// current position.
|
|
func readContainer(r io.Reader) error {
|
|
var magic [4]byte
|
|
if _, err := io.ReadFull(r, magic[:]); err != nil {
|
|
return fmt.Errorf("read RIFF magic: %w", err)
|
|
}
|
|
|
|
if string(magic[:]) == "RF64" {
|
|
return ErrRF64NotSupported
|
|
}
|
|
|
|
if string(magic[:]) != "RIFF" {
|
|
return 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 fmt.Errorf("read RIFF size: %w", err)
|
|
}
|
|
|
|
var form [4]byte
|
|
if _, err := io.ReadFull(r, form[:]); err != nil {
|
|
return fmt.Errorf("read WAVE form type: %w", err)
|
|
}
|
|
|
|
if string(form[:]) != "WAVE" {
|
|
return fmt.Errorf("%w: got %q", ErrNotWAVE, form)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// nextHeader reads one sub-chunk header. It returns io.EOF once the
|
|
// chunks are exhausted, including for a header cut short.
|
|
func nextHeader(r io.Reader) ([4]byte, uint32, error) {
|
|
var id [4]byte
|
|
|
|
_, err := io.ReadFull(r, id[:])
|
|
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
|
return id, 0, io.EOF
|
|
}
|
|
|
|
if err != nil {
|
|
return id, 0, fmt.Errorf("read chunk ID: %w", err)
|
|
}
|
|
|
|
var size uint32
|
|
if err := binary.Read(r, binary.LittleEndian, &size); err != nil {
|
|
return id, 0, fmt.Errorf("read chunk size for %q: %w", id, err)
|
|
}
|
|
|
|
return id, size, nil
|
|
}
|