[FLAC] Add iXML and BEXT support via APPLICATION blocks

Adds 6 public methods on FLAC::File mirroring RIFF::WAV::File's existing
iXML/BEXT API: iXMLData/setiXMLData/hasiXMLData and the BEXT equivalents.

Reads APPLICATION blocks (RFC 9639 § 8.4) carrying either the IANA-
registered "riff" foreign-metadata wrapper or the direct "iXML" / "bext"
application IDs used by some third-party tools (e.g. Sequoia). Writes
the spec-blessed "riff"-wrapped form. Unrecognized application IDs and
"riff"-wrapped chunks other than iXML/bext (e.g. "fmt ", "JUNK") flow
through unmodified, so existing files round-trip without churn.

Test coverage: read direct + riff-wrapped for both iXML and BEXT,
write+reread round-trip, empty-clears-block, and an unknown-application-
block preservation guard.
This commit is contained in:
Ryan Francesconi
2026-04-26 09:46:19 -07:00
committed by Urs Fleisch
parent e07b956fda
commit 1e7bdae284
3 changed files with 394 additions and 0 deletions

View File

@@ -70,6 +70,8 @@ public:
std::unique_ptr<Properties> properties;
ByteVector xiphCommentData;
String iXMLData;
ByteVector bextData;
List<FLAC::MetadataBlock *> blocks;
offset_t flacStart { 0 };
@@ -241,6 +243,52 @@ bool FLAC::File::save()
d->xiphCommentData = xiphComment()->render(false);
// Drop any APPLICATION blocks we recognize as iXML or bext from the block
// list. Recognized blocks were normally extracted to d->iXMLData /
// d->bextData during scan() and never added here, but this also catches
// entries inserted after scan() (defensive).
for(auto it = d->blocks.begin(); it != d->blocks.end();) {
if((*it)->code() == MetadataBlock::Application) {
const ByteVector blockData = (*it)->render();
if(blockData.size() >= 4) {
const ByteVector appId = blockData.mid(0, 4);
ByteVector innerId;
if(appId == "riff" && blockData.size() >= 12)
innerId = blockData.mid(4, 4);
else if(appId == "iXML" || appId == "bext")
innerId = appId;
if(innerId == "iXML" || innerId == "bext") {
delete *it;
it = d->blocks.erase(it);
continue;
}
}
}
++it;
}
// Append fresh APPLICATION/"riff" blocks for iXML and bext if non-empty.
// Per FLAC foreign-metadata convention the payload is a RIFF chunk:
// <4 byte FOURCC><4 byte LE size><data>.
if(!d->iXMLData.isEmpty()) {
const ByteVector xml = d->iXMLData.data(String::UTF8);
ByteVector payload;
payload.append("riff");
payload.append("iXML");
payload.append(ByteVector::fromUInt(xml.size(), false));
payload.append(xml);
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
}
if(!d->bextData.isEmpty()) {
ByteVector payload;
payload.append("riff");
payload.append("bext");
payload.append(ByteVector::fromUInt(d->bextData.size(), false));
payload.append(d->bextData);
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
}
// Replace metadata blocks
MetadataBlock *commentBlock =
@@ -433,6 +481,26 @@ void FLAC::File::removePictures()
}
}
String FLAC::File::iXMLData() const
{
return d->iXMLData;
}
void FLAC::File::setiXMLData(const String &data)
{
d->iXMLData = data;
}
ByteVector FLAC::File::BEXTData() const
{
return d->bextData;
}
void FLAC::File::setBEXTData(const ByteVector &data)
{
d->bextData = data;
}
void FLAC::File::strip(int tags)
{
if(tags & ID3v1)
@@ -462,6 +530,16 @@ bool FLAC::File::hasID3v2Tag() const
return d->ID3v2Location >= 0;
}
bool FLAC::File::hasiXMLData() const
{
return !d->iXMLData.isEmpty();
}
bool FLAC::File::hasBEXTData() const
{
return !d->bextData.isEmpty();
}
////////////////////////////////////////////////////////////////////////////////
// private members
////////////////////////////////////////////////////////////////////////////////
@@ -613,6 +691,49 @@ void FLAC::File::scan()
else if(blockType == MetadataBlock::Padding) {
// Skip all padding blocks.
}
else if(blockType == MetadataBlock::Application && data.size() >= 4) {
// APPLICATION block (RFC 9639 § 8.4):
// <4 bytes> big-endian application ID (ASCII FOURCC in practice)
// <n bytes> application-defined data
//
// We recognize two conventions for carrying RIFF iXML / bext metadata:
// 1. App ID "riff" — IANA-registered FLAC foreign-metadata wrapper.
// Payload is a RIFF chunk: <4 byte FOURCC><4 byte LE size><data>.
// 2. App ID "iXML" or "bext" — direct, used by some third-party tools
// (e.g. Sequoia). Payload is the chunk data verbatim.
//
// Other application IDs (and "riff" wrapping FOURCCs we don't recognize)
// fall through to UnknownMetadataBlock so they round-trip unchanged.
const ByteVector appId = data.mid(0, 4);
ByteVector innerId;
ByteVector innerData;
if(appId == "riff" && data.size() >= 12) {
innerId = data.mid(4, 4);
const unsigned int innerSize = data.toUInt(8U, false);
innerData = data.mid(12, innerSize);
}
else if(appId == "iXML" || appId == "bext") {
innerId = appId;
innerData = data.mid(4);
}
if(innerId == "iXML") {
if(d->iXMLData.isEmpty())
d->iXMLData = String(innerData, String::UTF8);
else
debug("FLAC::File::scan() -- multiple iXML blocks found, discarding");
}
else if(innerId == "bext") {
if(d->bextData.isEmpty())
d->bextData = innerData;
else
debug("FLAC::File::scan() -- multiple BEXT blocks found, discarding");
}
else {
block = new UnknownMetadataBlock(blockType, data);
}
}
else {
block = new UnknownMetadataBlock(blockType, data);
}

View File

@@ -296,6 +296,52 @@ namespace TagLib {
*/
void addPicture(Picture *picture);
/*!
* Returns the raw iXML data as a String. Empty if no iXML metadata
* is present. Read from an APPLICATION metadata block (RFC 9639 § 8.4)
* carrying either the FLAC foreign-metadata application ID "riff"
* (with an iXML RIFF chunk as payload) or the direct application ID
* "iXML" used by some third-party tools.
*
* \see setiXMLData()
* \see hasiXMLData()
*/
String iXMLData() const;
/*!
* Sets the iXML data. Pass an empty string to remove the iXML
* APPLICATION block on save. On save, the data is written using the
* FLAC foreign-metadata convention: an APPLICATION block with
* application ID "riff" wrapping an iXML RIFF chunk.
*
* \see iXMLData()
* \see hasiXMLData()
*/
void setiXMLData(const String &data);
/*!
* Returns the raw BEXT (Broadcast Audio Extension) data as a
* ByteVector. Empty if no BEXT metadata is present. Read from an
* APPLICATION metadata block (RFC 9639 § 8.4) carrying either the FLAC
* foreign-metadata application ID "riff" (with a bext RIFF chunk as
* payload) or the direct application ID "bext".
*
* \see setBEXTData()
* \see hasBEXTData()
*/
ByteVector BEXTData() const;
/*!
* Sets the BEXT data. Pass an empty ByteVector to remove the BEXT
* APPLICATION block on save. On save, the data is written using the
* FLAC foreign-metadata convention: an APPLICATION block with
* application ID "riff" wrapping a bext RIFF chunk.
*
* \see BEXTData()
* \see hasBEXTData()
*/
void setBEXTData(const ByteVector &data);
/*!
* This will remove the tags that match the OR-ed together TagTypes from
* the file. By default it removes all tags.
@@ -332,6 +378,22 @@ namespace TagLib {
*/
bool hasID3v2Tag() const;
/*!
* Returns whether or not the file on disk actually has iXML data
* stored in an APPLICATION metadata block.
*
* \see iXMLData()
*/
bool hasiXMLData() const;
/*!
* Returns whether or not the file on disk actually has BEXT data
* stored in an APPLICATION metadata block.
*
* \see BEXTData()
*/
bool hasBEXTData() const;
/*!
* Returns whether or not the given \a stream can be opened as a FLAC
* file.

View File

@@ -28,6 +28,7 @@
#include "tstringlist.h"
#include "tpropertymap.h"
#include "tbytevectorstream.h"
#include "tag.h"
#include "flacfile.h"
#include "xiphcomment.h"
@@ -67,6 +68,13 @@ class TestFLAC : public CppUnit::TestFixture
CPPUNIT_TEST(testRemoveXiphField);
CPPUNIT_TEST(testEmptySeekTable);
CPPUNIT_TEST(testPictureStoredAfterComment);
CPPUNIT_TEST(testReadiXMLDirect);
CPPUNIT_TEST(testReadiXMLRiffWrapped);
CPPUNIT_TEST(testReadBEXTDirect);
CPPUNIT_TEST(testReadBEXTRiffWrapped);
CPPUNIT_TEST(testWriteiXMLAndBEXT);
CPPUNIT_TEST(testWriteEmptyClearsiXMLAndBEXT);
CPPUNIT_TEST(testRoundTripPreservesUnknownApplicationBlock);
CPPUNIT_TEST_SUITE_END();
public:
@@ -663,6 +671,209 @@ public:
CPPUNIT_ASSERT(fileData.startsWith(expectedData));
}
// Build a 4-byte FLAC metadata-block header:
// <1 bit last><7 bit type><24 bit length, big-endian>.
static ByteVector flacBlockHeader(unsigned int payloadSize, int blockType, bool isLast)
{
ByteVector h = ByteVector::fromUInt(payloadSize);
h[0] = static_cast<char>(blockType | (isLast ? 0x80 : 0x00));
return h;
}
// Build the body of an APPLICATION/"riff"-wrapped RIFF chunk:
// [appID="riff"][FOURCC][LE size][data].
static ByteVector riffWrappedAppData(const ByteVector &fourcc, const ByteVector &data)
{
ByteVector body("riff", 4);
body.append(fourcc);
body.append(ByteVector::fromUInt(data.size(), false));
body.append(data);
return body;
}
// Build a minimal synthetic FLAC stream: "fLaC" + zero-init STREAMINFO +
// one APPLICATION block (which gets the last-block flag). Caller passes
// the full APPLICATION block payload starting with the 4-byte appID.
static ByteVector synthFlacWithApp(const ByteVector &appPayload)
{
ByteVector flac("fLaC", 4);
flac.append(flacBlockHeader(34, 0, false)); // STREAMINFO header
flac.append(ByteVector(34, '\0')); // STREAMINFO body
flac.append(flacBlockHeader(appPayload.size(), 2, true));
flac.append(appPayload);
return flac;
}
void testReadiXMLDirect()
{
const String xml("<BWFXML><IXML_VERSION>1.0</IXML_VERSION></BWFXML>");
ByteVector appPayload("iXML", 4);
appPayload.append(xml.data(String::UTF8));
ByteVector data = synthFlacWithApp(appPayload);
ByteVectorStream stream(data);
FLAC::File f(&stream, false);
CPPUNIT_ASSERT(f.isValid());
CPPUNIT_ASSERT(f.hasiXMLData());
CPPUNIT_ASSERT(!f.hasBEXTData());
CPPUNIT_ASSERT_EQUAL(xml, f.iXMLData());
}
void testReadiXMLRiffWrapped()
{
const String xml("<BWFXML><SCENE>1</SCENE></BWFXML>");
const ByteVector appPayload =
riffWrappedAppData("iXML", xml.data(String::UTF8));
ByteVector data = synthFlacWithApp(appPayload);
ByteVectorStream stream(data);
FLAC::File f(&stream, false);
CPPUNIT_ASSERT(f.isValid());
CPPUNIT_ASSERT(f.hasiXMLData());
CPPUNIT_ASSERT_EQUAL(xml, f.iXMLData());
}
void testReadBEXTDirect()
{
const ByteVector bext("test bext data");
ByteVector appPayload("bext", 4);
appPayload.append(bext);
ByteVector data = synthFlacWithApp(appPayload);
ByteVectorStream stream(data);
FLAC::File f(&stream, false);
CPPUNIT_ASSERT(f.isValid());
CPPUNIT_ASSERT(f.hasBEXTData());
CPPUNIT_ASSERT(!f.hasiXMLData());
CPPUNIT_ASSERT_EQUAL(bext, f.BEXTData());
}
void testReadBEXTRiffWrapped()
{
const ByteVector bext("test bext data");
const ByteVector appPayload = riffWrappedAppData("bext", bext);
ByteVector data = synthFlacWithApp(appPayload);
ByteVectorStream stream(data);
FLAC::File f(&stream, false);
CPPUNIT_ASSERT(f.isValid());
CPPUNIT_ASSERT(f.hasBEXTData());
CPPUNIT_ASSERT_EQUAL(bext, f.BEXTData());
}
void testWriteiXMLAndBEXT()
{
ScopedFileCopy copy("silence-44-s", ".flac");
const string newname = copy.fileName();
const String xml("<BWFXML><IXML_VERSION>1.6</IXML_VERSION></BWFXML>");
const ByteVector bext("bext payload bytes");
{
FLAC::File f(newname.c_str());
CPPUNIT_ASSERT(!f.hasiXMLData());
CPPUNIT_ASSERT(!f.hasBEXTData());
f.setiXMLData(xml);
f.setBEXTData(bext);
f.save();
}
{
FLAC::File f(newname.c_str());
CPPUNIT_ASSERT(f.hasiXMLData());
CPPUNIT_ASSERT(f.hasBEXTData());
CPPUNIT_ASSERT_EQUAL(xml, f.iXMLData());
CPPUNIT_ASSERT_EQUAL(bext, f.BEXTData());
}
// On-disk format check: written blocks must use the IANA-registered
// "riff" wrapper, not the direct "iXML"/"bext" application IDs.
const ByteVector fileBytes = PlainFile(newname.c_str()).readAll();
ByteVector expectediXMLApp("riff", 4);
expectediXMLApp.append("iXML");
expectediXMLApp.append(ByteVector::fromUInt(xml.data(String::UTF8).size(), false));
expectediXMLApp.append(xml.data(String::UTF8));
CPPUNIT_ASSERT(fileBytes.find(expectediXMLApp) >= 0);
ByteVector expectedBEXTApp("riff", 4);
expectedBEXTApp.append("bext");
expectedBEXTApp.append(ByteVector::fromUInt(bext.size(), false));
expectedBEXTApp.append(bext);
CPPUNIT_ASSERT(fileBytes.find(expectedBEXTApp) >= 0);
}
void testWriteEmptyClearsiXMLAndBEXT()
{
ScopedFileCopy copy("silence-44-s", ".flac");
const string newname = copy.fileName();
{
FLAC::File f(newname.c_str());
f.setiXMLData("<BWFXML/>");
f.setBEXTData(ByteVector("bext"));
f.save();
}
{
FLAC::File f(newname.c_str());
CPPUNIT_ASSERT(f.hasiXMLData());
CPPUNIT_ASSERT(f.hasBEXTData());
f.setiXMLData(String());
f.setBEXTData(ByteVector());
f.save();
}
{
FLAC::File f(newname.c_str());
CPPUNIT_ASSERT(!f.hasiXMLData());
CPPUNIT_ASSERT(!f.hasBEXTData());
CPPUNIT_ASSERT(f.iXMLData().isEmpty());
CPPUNIT_ASSERT(f.BEXTData().isEmpty());
}
}
void testRoundTripPreservesUnknownApplicationBlock()
{
// Source: silence-44-s with an extra APPLICATION/"SMED" block injected
// just before its existing VORBIS_COMMENT block. Goal: setting iXML and
// saving must not disturb the SMED block (it's an unrecognized app ID).
const ByteVector smedAppPayload("SMED", 4);
ByteVector smedExtra("opaque sequoia metadata payload");
ByteVector smedBlock = smedAppPayload;
smedBlock.append(smedExtra);
// Splice a fresh APPLICATION/SMED block into a synthetic FLAC. Use the
// file we just built as the input stream so we don't have to mutate a
// real FLAC's seek table / picture offsets.
ByteVector flac("fLaC", 4);
flac.append(flacBlockHeader(34, 0, false));
flac.append(ByteVector(34, '\0'));
flac.append(flacBlockHeader(smedBlock.size(), 2, true));
flac.append(smedBlock);
ByteVectorStream stream(flac);
{
FLAC::File f(&stream, false);
CPPUNIT_ASSERT(f.isValid());
CPPUNIT_ASSERT(!f.hasiXMLData());
f.setiXMLData("<BWFXML/>");
f.save();
}
// SMED block must still be present on disk after save.
ByteVector saved = *stream.data();
CPPUNIT_ASSERT(saved.find(smedAppPayload) >= 0);
CPPUNIT_ASSERT(saved.find(smedExtra) >= 0);
// And the iXML data must round-trip.
ByteVectorStream stream2(saved);
FLAC::File f2(&stream2, false);
CPPUNIT_ASSERT(f2.isValid());
CPPUNIT_ASSERT(f2.hasiXMLData());
CPPUNIT_ASSERT_EQUAL(String("<BWFXML/>"), f2.iXMLData());
}
};
CPPUNIT_TEST_SUITE_REGISTRATION(TestFLAC);