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.
This commit is contained in:
Ryan Francesconi
2026-04-04 03:14:34 -07:00
committed by GitHub
parent 49510e7d5a
commit abadbb6768
3 changed files with 262 additions and 0 deletions

View File

@ -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<RIFF::Info::Tag>(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<TagTypes>(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])

View File

@ -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.

View File

@ -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("<BWFXML><IXML_VERSION>1.0</IXML_VERSION></BWFXML>");
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("<BWFXML><IXML_VERSION>1.0</IXML_VERSION></BWFXML>"),
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("<BWFXML><SCENE>1</SCENE></BWFXML>");
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("<BWFXML><SCENE>1</SCENE></BWFXML>"),
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);