From 3ace0483c0d7a2a4c1dbf9277274f0e733530504 Mon Sep 17 00:00:00 2001 From: Ryan Francesconi <2917795+ryanfrancesconi@users.noreply.github.com> Date: Thu, 13 Aug 2026 21:47:05 -0700 Subject: [PATCH] RIFF: support RF64 and BW64 (#1412) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RF64 and BW64 are the long forms of WAVE, used past 4 GB: each 32-bit size field holds a 0xffffffff sentinel and the real sizes live in a leading ds64 chunk. RIFF::WAV::File::isSupported() rejected them, but FileRef reaches the class by extension for any .wav and RIFF::File::read() never inspected the magic, so these files opened as valid. updateGlobalSize() then wrote a real 32-bit total over the sentinel at offset 4. Readers stop consulting ds64 once that field holds a number, so a 4.8 GB file measured 0.005958 sec and 1144 audio bytes after a tag save that returned true. Below 4 GB the tags were lost instead: the appended LIST landed inside the region read() had clamped the sentinel data chunk to, and a re-read found no properties. Worse, the append offset is last.offset + last.size with size truncated to 0xffffffff, so on a long file the new chunk was spliced into the middle of the audio — measured at offset 4294971392 on that same file, 4 GiB past the data chunk's start, displacing everything after it. Accept both magics, take the riff and data sizes from ds64, and write the sentinel back on save. Writing it unconditionally is what the format requires and also repairs a file an earlier version damaged: the same 4.8 GB file, clobbered and then saved through this path, read back at 25000.000000 sec and 4,800,000,000 bytes. The ds64 table of additional oversized chunks is not parsed — the data chunk has its own dedicated field and is the only one that is ever large — so any chunk listed there stays on the clamping path added in #1329, which this leaves untouched. Chunk::size becomes offset_t so the append offset is computed correctly. The struct is file-scope in rifffile.cpp and FilePrivate is only forward-declared, so no protected signature changes and no ABI break. chunkDataSize() still returns unsigned int, saturating rather than truncating; a new chunkDataSize64() carries the real value to WAV::Properties, which otherwise reports 0 s for a long file. AIFF is big-endian and has no long form, so RIFF::File's only other subclass cannot reach the new branch. tests/data/rf64.wav is 9,680 bytes and built by construction, not by an encoder; Core Audio reads it as RF64 at 0.050000 sec. The sentinels behave identically at any size, so a small fixture covers the detection failure, the sentinel overwrite and the repair. --- taglib/riff/rifffile.cpp | 78 ++++++++++++++-- taglib/riff/rifffile.h | 10 ++- taglib/riff/wav/wavfile.cpp | 6 +- taglib/riff/wav/wavproperties.cpp | 17 ++-- tests/data/rf64.wav | Bin 0 -> 9680 bytes tests/test_wav.cpp | 142 ++++++++++++++++++++++++++++++ 6 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 tests/data/rf64.wav diff --git a/taglib/riff/rifffile.cpp b/taglib/riff/rifffile.cpp index d4a7ca59..48bc835e 100644 --- a/taglib/riff/rifffile.cpp +++ b/taglib/riff/rifffile.cpp @@ -43,7 +43,8 @@ struct Chunk { ByteVector name; offset_t offset; - unsigned int size; + //! May exceed 32 bits for the "data" chunk of an RF64/BW64 file. + offset_t size; unsigned int padding; }; @@ -60,6 +61,13 @@ public: unsigned int size { 0 }; offset_t sizeOffset { 0 }; + //! An RF64 or BW64 file: the 32-bit size fields hold a 0xffffffff sentinel and + //! the real sizes live in a leading "ds64" chunk. + bool isLongForm { false }; + //! Offset of the "ds64" chunk data, or 0 if the file has none. + offset_t ds64Offset { 0 }; + offset_t dataSize64 { 0 }; + std::vector chunks; }; @@ -100,9 +108,14 @@ unsigned int RIFF::File::chunkCount() const } unsigned int RIFF::File::chunkDataSize(unsigned int i) const +{ + return static_cast(std::min(chunkDataSize64(i), 0xffffffff)); +} + +offset_t RIFF::File::chunkDataSize64(unsigned int i) const { if(i >= d->chunks.size()) { - debug("RIFF::File::chunkDataSize() - Index out of range. Returning 0."); + debug("RIFF::File::chunkDataSize64() - Index out of range. Returning 0."); return 0; } @@ -147,7 +160,10 @@ ByteVector RIFF::File::chunkData(unsigned int i) } seek(d->chunks[i].offset); - return readBlock(d->chunks[i].size); + + // A ByteVector is limited to 32 bits. The only chunk that can be larger is the + // "data" chunk of an RF64 file, which no caller reads through this API. + return readBlock(static_cast(std::min(d->chunks[i].size, 0xffffffff))); } void RIFF::File::setChunkData(unsigned int i, const ByteVector &data) @@ -259,8 +275,8 @@ void RIFF::File::removeChunk(unsigned int i) auto it = d->chunks.begin(); std::advance(it, i); - const unsigned int removeSize = it->size + it->padding + 8; - removeBlock(it->offset - 8, removeSize); + const offset_t removeSize = it->size + it->padding + 8; + removeBlock(it->offset - 8, static_cast(removeSize)); it = d->chunks.erase(it); while(it != d->chunks.end()) { @@ -291,6 +307,15 @@ void RIFF::File::read() offset_t offset = tell(); + // RF64 and BW64 are the long forms of WAVE, used past 4 GB: the 32-bit size fields + // hold a 0xffffffff sentinel and a leading "ds64" chunk carries the real sizes. + // Both are little-endian, so AIFF never takes this path. + if(!bigEndian) { + seek(offset); + const ByteVector magic = readBlock(4); + d->isLongForm = magic == "RF64" || magic == "BW64"; + } + offset += 4; d->sizeOffset = offset; @@ -310,20 +335,36 @@ void RIFF::File::read() seek(offset); const ByteVector chnkName = readBlock(4); - unsigned int chunkSize = readBlock(4).toUInt(bigEndian); + const unsigned int declaredSize = readBlock(4).toUInt(bigEndian); if(!isValidChunkName(chnkName)) { debug("RIFF::File::read() -- Chunk '" + chnkName + "' has invalid ID"); break; } - if(static_cast(offset) + 8 + chunkSize > length()) { + // "ds64" is required to be the first chunk, so its sizes are known by the time + // the "data" chunk is reached. Only the four fixed fields are read; the table of + // additional oversized chunks that may follow them is not parsed, and any chunk + // listed there stays on the clamping path below. + if(d->isLongForm && chnkName == "ds64" && d->chunks.empty() && declaredSize >= 28) { + seek(offset + 8); + const ByteVector ds64 = readBlock(28); + d->ds64Offset = offset + 8; + d->dataSize64 = static_cast(ds64.toULongLong(8, bigEndian)); + } + + offset_t chunkSize = declaredSize; + + if(d->isLongForm && chnkName == "data" && declaredSize == 0xffffffff && d->dataSize64 > 0) + chunkSize = d->dataSize64; + + if(offset + 8 + chunkSize > length()) { // Clamp to available bytes rather than rejecting the chunk outright. // Some encoders write a correct data chunk but with a slightly too-large // declared size, or place the data chunk outside the declared RIFF boundary. // Lenient parsers (ffmpeg, QuickTime) handle this by clamping; we do the same. debug("RIFF::File::read() -- Chunk '" + chnkName + "' is truncated; clamping size to available bytes."); - chunkSize = static_cast(length() - offset - 8); + chunkSize = length() - offset - 8; } Chunk chunk; @@ -381,7 +422,26 @@ void RIFF::File::updateGlobalSize() const Chunk first = d->chunks.front(); const Chunk last = d->chunks.back(); - d->size = static_cast(last.offset + last.size + last.padding - first.offset + 12); + const offset_t totalSize = last.offset + last.size + last.padding - first.offset + 12; + + if(d->isLongForm) { + // A long-form file always carries the sentinel here and its real size in "ds64"; any other + // value is malformed. Writing it unconditionally also repairs a file whose sentinel an + // older writer replaced with a real total, which past 4 GB is a truncated value that makes + // readers stop consulting "ds64" and believe it instead. + d->size = 0xffffffff; + insert(ByteVector::fromUInt(d->size, d->endianness == BigEndian), d->sizeOffset, 4); + + // The "data" chunk's own size and "ds64"'s copy of it are left alone because no write path + // here changes the audio. + if(d->ds64Offset > 0) + insert(ByteVector::fromULongLong(totalSize, d->endianness == BigEndian), + d->ds64Offset, 8); + + return; + } + + d->size = static_cast(totalSize); const ByteVector data = ByteVector::fromUInt(d->size, d->endianness == BigEndian); insert(data, d->sizeOffset, 4); diff --git a/taglib/riff/rifffile.h b/taglib/riff/rifffile.h index 75fcc5af..8dc521aa 100644 --- a/taglib/riff/rifffile.h +++ b/taglib/riff/rifffile.h @@ -77,10 +77,18 @@ namespace TagLib { offset_t chunkOffset(unsigned int i) const; /*! - * \return The size of the chunk data. + * \return The size of the chunk data, saturated at 0xffffffff. + * + * \note The "data" chunk of an RF64 or BW64 file can be larger than this can + * express; use chunkDataSize64() where that matters. */ unsigned int chunkDataSize(unsigned int i) const; + /*! + * \return The size of the chunk data, without a 32-bit limit. + */ + offset_t chunkDataSize64(unsigned int i) const; + /*! * \return The size of the padding after the chunk (can be either 0 or 1). */ diff --git a/taglib/riff/wav/wavfile.cpp b/taglib/riff/wav/wavfile.cpp index 2809d083..821a70b8 100644 --- a/taglib/riff/wav/wavfile.cpp +++ b/taglib/riff/wav/wavfile.cpp @@ -68,10 +68,12 @@ public: bool RIFF::WAV::File::isSupported(IOStream *stream) { - // A WAV file has to start with "RIFF????WAVE". + // A WAV file has to start with "RIFF????WAVE", or with the long-form "RF64" or + // "BW64" magic used past 4 GB. const ByteVector id = Utils::readHeader(stream, 12, false); - return id.startsWith("RIFF") && id.containsAt("WAVE", 8); + return (id.startsWith("RIFF") || id.startsWith("RF64") || id.startsWith("BW64")) && + id.containsAt("WAVE", 8); } //////////////////////////////////////////////////////////////////////////////// diff --git a/taglib/riff/wav/wavproperties.cpp b/taglib/riff/wav/wavproperties.cpp index f1494c8b..b8eb5d9f 100644 --- a/taglib/riff/wav/wavproperties.cpp +++ b/taglib/riff/wav/wavproperties.cpp @@ -25,6 +25,8 @@ #include "wavproperties.h" +#include + #include "tdebug.h" #include "wavfile.h" @@ -108,7 +110,8 @@ int RIFF::WAV::Properties::format() const void RIFF::WAV::Properties::read(File *file) { ByteVector data; - unsigned int streamLength = 0; + // 64-bit: an RF64 "data" chunk does not fit in 32. + offset_t streamLength = 0; unsigned int totalSamples = 0; for(unsigned int i = 0; i < file->chunkCount(); ++i) { @@ -120,7 +123,7 @@ void RIFF::WAV::Properties::read(File *file) } else if(name == "data") { if(streamLength == 0) - streamLength = file->chunkDataSize(i) + file->chunkPadding(i); + streamLength = file->chunkDataSize64(i) + file->chunkPadding(i); else debug("RIFF::WAV::Properties::read() - Duplicate 'data' chunk found."); } @@ -162,17 +165,19 @@ void RIFF::WAV::Properties::read(File *file) if(d->format != FORMAT_PCM && (d->format != FORMAT_IEEE_FLOAT || totalSamples != 0)) d->sampleFrames = totalSamples; - else if(d->channels > 0 && d->bitsPerSample > 0) - d->sampleFrames = streamLength / (d->channels * ((d->bitsPerSample + 7) / 8)); + else if(d->channels > 0 && d->bitsPerSample > 0) { + const offset_t frames = streamLength / (d->channels * ((d->bitsPerSample + 7) / 8)); + d->sampleFrames = static_cast(std::min(frames, 0xffffffff)); + } if(d->sampleFrames > 0 && d->sampleRate > 0) { const auto length = static_cast(d->sampleFrames) * 1000.0 / d->sampleRate; d->length = static_cast(length + 0.5); - d->bitrate = static_cast(streamLength * 8.0 / length + 0.5); + d->bitrate = static_cast(static_cast(streamLength) * 8.0 / length + 0.5); } else { if(const unsigned int byteRate = data.toUInt(8, false); byteRate > 0) { - d->length = static_cast(streamLength * 1000.0 / byteRate + 0.5); + d->length = static_cast(static_cast(streamLength) * 1000.0 / byteRate + 0.5); d->bitrate = static_cast(byteRate * 8.0 / 1000.0 + 0.5); } } diff --git a/tests/data/rf64.wav b/tests/data/rf64.wav new file mode 100644 index 0000000000000000000000000000000000000000..b694e1a0c5ece7be69548d497f6e20b7e7817041 GIT binary patch literal 9680 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 literal 0 HcmV?d00001 diff --git a/tests/test_wav.cpp b/tests/test_wav.cpp index ea2f8e20..309e844b 100644 --- a/tests/test_wav.cpp +++ b/tests/test_wav.cpp @@ -60,6 +60,10 @@ class TestWAV : public CppUnit::TestFixture CPPUNIT_TEST(testPCMWithFactChunk); CPPUNIT_TEST(testWaveFormatExtensible); CPPUNIT_TEST(testInvalidChunk); + CPPUNIT_TEST(testRF64IsSupported); + CPPUNIT_TEST(testRF64Properties); + CPPUNIT_TEST(testRF64Save); + CPPUNIT_TEST(testRF64SaveRepairsClobberedSize); CPPUNIT_TEST(testRIFFInfoProperties); CPPUNIT_TEST(testBEXTTag); CPPUNIT_TEST(testBEXTTagWithOtherTags); @@ -408,6 +412,144 @@ public: } } + // rf64.wav is a 50 ms RF64: 0xffffffff sentinels in the 32-bit size fields at offset 4 and + // in the "data" chunk header, with the real sizes in a leading "ds64" chunk. That is what a + // WAVE file becomes past 4 GB; the sentinels behave the same at any size, so the fixture is + // small. + + static void setMagic(const std::string &fileName, const ByteVector &magic) + { + FileStream stream(fileName.c_str()); + stream.seek(0); + stream.writeBlock(magic); + } + + void testRF64IsSupported() + { + ScopedFileCopy copy("rf64", ".wav"); + { + FileStream stream(copy.fileName().c_str(), true); + CPPUNIT_ASSERT(RIFF::WAV::File::isSupported(&stream)); + } + setMagic(copy.fileName(), "BW64"); + { + FileStream stream(copy.fileName().c_str(), true); + CPPUNIT_ASSERT(RIFF::WAV::File::isSupported(&stream)); + } + setMagic(copy.fileName(), "XX64"); + { + FileStream stream(copy.fileName().c_str(), true); + CPPUNIT_ASSERT(!RIFF::WAV::File::isSupported(&stream)); + } + } + + void testRF64Properties() + { + ScopedFileCopy copy("rf64", ".wav"); + + // Bytes past the audio, so that clamping the sentinel to what is available gives a + // different answer from "ds64" and the test can tell which one was used. + { + FileStream stream(copy.fileName().c_str()); + stream.seek(0, IOStream::End); + stream.writeBlock(ByteVector("junk", 4) + ByteVector::fromUInt(1000, false) + + ByteVector(1000, '\0')); + } + + RIFF::WAV::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(50, f.audioProperties()->lengthInMilliseconds()); + CPPUNIT_ASSERT_EQUAL(48000, f.audioProperties()->sampleRate()); + CPPUNIT_ASSERT_EQUAL(2, f.audioProperties()->channels()); + } + + void testRF64Save() + { + ScopedFileCopy copy("rf64", ".wav"); + + offset_t originalLength = 0; + { + FileStream stream(copy.fileName().c_str(), true); + originalLength = stream.length(); + } + + { + RIFF::WAV::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(f.isValid()); + PropertyMap properties; + properties["TITLE"] = StringList("Title"); + properties["ARTIST"] = StringList("Artist"); + CPPUNIT_ASSERT(f.setProperties(properties).isEmpty()); + CPPUNIT_ASSERT(f.save()); + } + + { + RIFF::WAV::File f(copy.fileName().c_str()); + const PropertyMap properties = f.properties(); + CPPUNIT_ASSERT(properties.contains("TITLE")); + CPPUNIT_ASSERT(properties.contains("ARTIST")); + CPPUNIT_ASSERT_EQUAL(String("Title"), properties["TITLE"].front()); + CPPUNIT_ASSERT_EQUAL(String("Artist"), properties["ARTIST"].front()); + CPPUNIT_ASSERT_EQUAL(50, f.audioProperties()->lengthInMilliseconds()); + } + + { + FileStream stream(copy.fileName().c_str(), true); + const offset_t length = stream.length(); + CPPUNIT_ASSERT(length > originalLength); + + // The 32-bit field has to stay a sentinel: a real number there makes readers stop + // consulting "ds64", which past 4 GB is the only place the size fits. + stream.seek(4); + CPPUNIT_ASSERT_EQUAL(0xffffffffU, stream.readBlock(4).toUInt(false)); + + // "ds64" carries the real size, so it is what has to track the file's growth. + stream.seek(20); + CPPUNIT_ASSERT_EQUAL(static_cast(length - 8), + stream.readBlock(8).toULongLong(false)); + + // The audio's own extent is untouched. + stream.seek(28); + CPPUNIT_ASSERT_EQUAL(9600ULL, stream.readBlock(8).toULongLong(false)); + } + } + + void testRF64SaveRepairsClobberedSize() + { + ScopedFileCopy copy("rf64", ".wav"); + + // A real total where the sentinel belongs, as an earlier version of this code left it. The + // value is malformed in a long-form file at any size, and past 4 GB it is also truncated, + // which is what makes readers report milliseconds for hours of audio. + { + FileStream stream(copy.fileName().c_str()); + stream.seek(4); + stream.writeBlock(ByteVector::fromUInt(5230, false)); + } + + { + RIFF::WAV::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(f.isValid()); + f.InfoTag()->setTitle("Title"); + CPPUNIT_ASSERT(f.save()); + } + + { + FileStream stream(copy.fileName().c_str(), true); + const offset_t length = stream.length(); + + stream.seek(4); + CPPUNIT_ASSERT_EQUAL(0xffffffffU, stream.readBlock(4).toUInt(false)); + + stream.seek(20); + CPPUNIT_ASSERT_EQUAL(static_cast(length - 8), + stream.readBlock(8).toULongLong(false)); + + stream.seek(28); + CPPUNIT_ASSERT_EQUAL(9600ULL, stream.readBlock(8).toULongLong(false)); + } + } + void testRIFFInfoProperties() { PropertyMap tags;