From abadbb676889f9e5980edc4600b43bc2d2d73355 Mon Sep 17 00:00:00 2001 From: Ryan Francesconi <2917795+ryanfrancesconi@users.noreply.github.com> Date: Sat, 4 Apr 2026 03:14:34 -0700 Subject: [PATCH] Add BEXT and iXML chunk support to WAV files (#1323) Read, write, and remove Broadcast Audio Extension (BEXT, EBU Tech 3285) and iXML metadata chunks in WAV files. BEXT is widely used in broadcast and professional audio for originator, description, time reference, and loudness metadata. iXML is used by field recorders and DAWs for scene, take, and track metadata. --- taglib/riff/wav/wavfile.cpp | 63 +++++++++++++++ taglib/riff/wav/wavfile.h | 50 ++++++++++++ tests/test_wav.cpp | 149 ++++++++++++++++++++++++++++++++++++ 3 files changed, 262 insertions(+) diff --git a/taglib/riff/wav/wavfile.cpp b/taglib/riff/wav/wavfile.cpp index 8c7cdb85..26d9819b 100644 --- a/taglib/riff/wav/wavfile.cpp +++ b/taglib/riff/wav/wavfile.cpp @@ -55,6 +55,11 @@ public: bool hasID3v2 { false }; bool hasInfo { false }; + bool hasiXML { false }; + bool hasBEXT { false }; + + String iXMLData; + ByteVector bextData; }; //////////////////////////////////////////////////////////////////////////////// @@ -108,6 +113,26 @@ RIFF::Info::Tag *RIFF::WAV::File::InfoTag() const return d->tag.access(InfoIndex, false); } +String RIFF::WAV::File::iXMLData() const +{ + return d->iXMLData; +} + +void RIFF::WAV::File::setiXMLData(const String &data) +{ + d->iXMLData = data; +} + +ByteVector RIFF::WAV::File::BEXTData() const +{ + return d->bextData; +} + +void RIFF::WAV::File::setBEXTData(const ByteVector &data) +{ + d->bextData = data; +} + void RIFF::WAV::File::strip(TagTypes tags) { removeTagChunks(tags); @@ -160,6 +185,26 @@ bool RIFF::WAV::File::save(TagTypes tags, StripTags strip, ID3v2::Version versio if(strip == StripOthers) File::strip(static_cast(AllTags & ~tags)); + if(!d->bextData.isEmpty()) { + removeChunk("bext"); + setChunkData("bext", d->bextData); + d->hasBEXT = true; + } + else if(d->hasBEXT) { + removeChunk("bext"); + d->hasBEXT = false; + } + + if(!d->iXMLData.isEmpty()) { + removeChunk("iXML"); + setChunkData("iXML", d->iXMLData.data(String::UTF8)); + d->hasiXML = true; + } + else if(d->hasiXML) { + removeChunk("iXML"); + d->hasiXML = false; + } + if(tags & ID3v2) { removeTagChunks(ID3v2); @@ -191,6 +236,16 @@ bool RIFF::WAV::File::hasInfoTag() const return d->hasInfo; } +bool RIFF::WAV::File::hasiXMLData() const +{ + return d->hasiXML; +} + +bool RIFF::WAV::File::hasBEXTData() const +{ + return d->hasBEXT; +} + //////////////////////////////////////////////////////////////////////////////// // private members //////////////////////////////////////////////////////////////////////////////// @@ -219,6 +274,14 @@ void RIFF::WAV::File::read(bool readProperties) } } } + else if(name == "iXML") { + d->hasiXML = true; + d->iXMLData = String(chunkData(i)); + } + else if(name == "bext") { + d->hasBEXT = true; + d->bextData = chunkData(i); + } } if(!d->tag[ID3v2Index]) diff --git a/taglib/riff/wav/wavfile.h b/taglib/riff/wav/wavfile.h index 32330e51..5d34b2c4 100644 --- a/taglib/riff/wav/wavfile.h +++ b/taglib/riff/wav/wavfile.h @@ -134,6 +134,42 @@ namespace TagLib { */ Info::Tag *InfoTag() const; + /*! + * Returns the raw iXML chunk data as a String. + * Empty if no iXML chunk is present. + * + * \see setiXMLData() + * \see hasiXMLData() + */ + String iXMLData() const; + + /*! + * Sets the iXML chunk data. Pass an empty string to remove the + * iXML chunk on save. + * + * \see iXMLData() + * \see hasiXMLData() + */ + void setiXMLData(const String &data); + + /*! + * Returns the raw BEXT (Broadcast Audio Extension) chunk data + * as a ByteVector. Empty if no BEXT chunk is present. + * + * \see setBEXTData() + * \see hasBEXTData() + */ + ByteVector BEXTData() const; + + /*! + * Sets the BEXT chunk data. Pass an empty ByteVector to remove + * the BEXT chunk on save. + * + * \see BEXTData() + * \see hasBEXTData() + */ + void setBEXTData(const ByteVector &data); + /*! * This will strip the tags that match the OR-ed together TagTypes from the * file. By default it strips all tags. It returns \c true if the tags are @@ -191,6 +227,20 @@ namespace TagLib { */ bool hasInfoTag() const; + /*! + * Returns whether or not the file on disk actually has an iXML chunk. + * + * \see iXMLTag + */ + bool hasiXMLData() const; + + /*! + * Returns whether or not the file on disk actually has a BEXT chunk. + * + * \see bextTag + */ + bool hasBEXTData() const; + /*! * Returns whether or not the given \a stream can be opened as a WAV * file. diff --git a/tests/test_wav.cpp b/tests/test_wav.cpp index 5491dd67..5782a3c7 100644 --- a/tests/test_wav.cpp +++ b/tests/test_wav.cpp @@ -61,6 +61,10 @@ class TestWAV : public CppUnit::TestFixture CPPUNIT_TEST(testWaveFormatExtensible); CPPUNIT_TEST(testInvalidChunk); CPPUNIT_TEST(testRIFFInfoProperties); + CPPUNIT_TEST(testBEXTTag); + CPPUNIT_TEST(testBEXTTagWithOtherTags); + CPPUNIT_TEST(testiXMLTag); + CPPUNIT_TEST(testiXMLTagWithOtherTags); CPPUNIT_TEST_SUITE_END(); public: @@ -482,6 +486,151 @@ public: } } + void testBEXTTag() + { + ScopedFileCopy copy("empty", ".wav"); + string filename = copy.fileName(); + + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT(!f.hasBEXTData()); + CPPUNIT_ASSERT(f.BEXTData().isEmpty()); + + f.setBEXTData(ByteVector("test bext data")); + f.save(); + CPPUNIT_ASSERT(f.hasBEXTData()); + } + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT(f.hasBEXTData()); + CPPUNIT_ASSERT_EQUAL(ByteVector("test bext data"), f.BEXTData()); + + f.setBEXTData(ByteVector()); + f.save(); + CPPUNIT_ASSERT(!f.hasBEXTData()); + } + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT(!f.hasBEXTData()); + CPPUNIT_ASSERT(f.BEXTData().isEmpty()); + } + + // Check if file without BEXT is same as original empty file + const ByteVector origData = PlainFile(TEST_FILE_PATH_C("empty.wav")).readAll(); + const ByteVector fileData = PlainFile(filename.c_str()).readAll(); + CPPUNIT_ASSERT(origData == fileData); + } + + void testBEXTTagWithOtherTags() + { + ScopedFileCopy copy("empty", ".wav"); + string filename = copy.fileName(); + + { + RIFF::WAV::File f(filename.c_str()); + f.ID3v2Tag()->setTitle("ID3v2 Title"); + f.InfoTag()->setTitle("INFO Title"); + f.setBEXTData(ByteVector("bext payload")); + f.save(); + } + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.hasID3v2Tag()); + CPPUNIT_ASSERT(f.hasInfoTag()); + CPPUNIT_ASSERT(f.hasBEXTData()); + CPPUNIT_ASSERT_EQUAL(String("ID3v2 Title"), f.ID3v2Tag()->title()); + CPPUNIT_ASSERT_EQUAL(String("INFO Title"), f.InfoTag()->title()); + CPPUNIT_ASSERT_EQUAL(ByteVector("bext payload"), f.BEXTData()); + } + } + + void testiXMLTag() + { + ScopedFileCopy copy("empty", ".wav"); + string filename = copy.fileName(); + + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT(!f.hasiXMLData()); + CPPUNIT_ASSERT(f.iXMLData().isEmpty()); + + f.setiXMLData("1.0"); + f.save(); + CPPUNIT_ASSERT(f.hasiXMLData()); + } + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT(f.hasiXMLData()); + CPPUNIT_ASSERT_EQUAL( + String("1.0"), + f.iXMLData()); + + f.setiXMLData(String()); + f.save(); + CPPUNIT_ASSERT(!f.hasiXMLData()); + } + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT(!f.hasiXMLData()); + CPPUNIT_ASSERT(f.iXMLData().isEmpty()); + } + + // Check if file without iXML is same as original empty file + const ByteVector origData = PlainFile(TEST_FILE_PATH_C("empty.wav")).readAll(); + const ByteVector fileData = PlainFile(filename.c_str()).readAll(); + CPPUNIT_ASSERT(origData == fileData); + } + + void testiXMLTagWithOtherTags() + { + ScopedFileCopy copy("empty", ".wav"); + string filename = copy.fileName(); + + { + RIFF::WAV::File f(filename.c_str()); + f.ID3v2Tag()->setTitle("ID3v2 Title"); + f.setiXMLData("1"); + f.setBEXTData(ByteVector("bext data")); + f.save(); + } + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.hasID3v2Tag()); + CPPUNIT_ASSERT(f.hasiXMLData()); + CPPUNIT_ASSERT(f.hasBEXTData()); + CPPUNIT_ASSERT_EQUAL(String("ID3v2 Title"), f.ID3v2Tag()->title()); + CPPUNIT_ASSERT_EQUAL( + String("1"), + f.iXMLData()); + CPPUNIT_ASSERT_EQUAL(ByteVector("bext data"), f.BEXTData()); + + f.setiXMLData(String()); + f.setBEXTData(ByteVector()); + f.strip(); + CPPUNIT_ASSERT(f.save()); + } + { + RIFF::WAV::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT(!f.hasID3v2Tag()); + CPPUNIT_ASSERT(!f.hasiXMLData()); + CPPUNIT_ASSERT(f.iXMLData().isEmpty()); + CPPUNIT_ASSERT(!f.hasBEXTData()); + CPPUNIT_ASSERT(f.BEXTData().isEmpty()); + } + + // Check if file without tags is same as original empty file + const ByteVector origData = PlainFile(TEST_FILE_PATH_C("empty.wav")).readAll(); + const ByteVector fileData = PlainFile(filename.c_str()).readAll(); + CPPUNIT_ASSERT(origData == fileData); + } + }; CPPUNIT_TEST_SUITE_REGISTRATION(TestWAV);