[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

@@ -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);