Improve audio metadata extraction

This commit is contained in:
d0s1nt 2026-05-02 13:32:16 +01:00
parent a492a3c034
commit 4b3c612707
4 changed files with 1915 additions and 91 deletions

View File

@ -13,32 +13,24 @@ import { ascii4, indexOfAscii } from "./AudioBytes.mjs";
/** Builds the empty report skeleton ready for a format parser to populate. */
export function makeEmptyReport(filename, byteLength, container) {
return {
schema_version: "audio-meta-1.0",
schema_version: "audio-meta-1.2",
artifact: {
filename,
byte_length: byteLength,
container: { type: container.type, brand: container.brand || null, mime: container.mime || null },
},
detections: { metadata_systems: [], provenance_systems: [] },
detections: { metadata_systems: [], provenance_systems: [], metadata_sources: [] },
tags: {
common: {
title: null, artist: null, album: null, date: null, track: null,
genre: null, comment: null, composer: null, copyright: null, language: null,
},
common: {},
raw: {},
},
embedded: [],
metadata_sources: {},
provenance: {
c2pa: {
present: false,
embedding: [],
manifest_store: { active_manifest_urn: null, instance_id: null, claim_generator: null },
manifest_store: {},
assertions: [],
signature: {
algorithm: null, signing_time: null,
certificate: { subject_cn: null, issuer_cn: null, serial_number: null },
},
validation: { validation_state: "Unknown", reasons: [], details_raw: null },
},
},
errors: [],

File diff suppressed because it is too large Load Diff

View File

@ -4,6 +4,8 @@
* @license Apache-2.0
*/
/* eslint-disable camelcase */
import Operation from "../Operation.mjs";
import OperationError from "../errors/OperationError.mjs";
import Utils from "../Utils.mjs";
@ -55,7 +57,7 @@ class ExtractAudioMetadata extends Operation {
try {
const parsers = {
mp3: () => parseMp3(bytes, report),
mp3: () => parseMp3(bytes, report, maxTextBytes),
wav: () => parseRiffWave(bytes, report, maxTextBytes),
bw64: () => parseRiffWave(bytes, report, maxTextBytes),
flac: () => parseFlac(bytes, report, maxTextBytes),
@ -77,7 +79,7 @@ class ExtractAudioMetadata extends Operation {
report.errors.push({ stage: "parse", message: String(e?.message || e) });
}
return report;
return compactReport(report);
}
/** Renders the extracted metadata as an HTML table. */
@ -85,8 +87,24 @@ class ExtractAudioMetadata extends Operation {
if (!data || typeof data !== "object") return JSON.stringify(data, null, 4);
const esc = Utils.escapeHtml;
const row = (k, v) => `<tr><td>${esc(String(k))}</td><td>${esc(String(v ?? ""))}</td></tr>\n`;
const section = (title) => `<tr><th colspan="2" style="background:#e9ecef;text-align:center">${esc(title)}</th></tr>\n`;
const formatValue = (v) => {
if (v === null) return "null";
if (v === undefined) return "";
if (typeof v === "object") return JSON.stringify(v);
return String(v);
};
const renderValue = (v) => {
const formatted = formatValue(v);
const escaped = esc(formatted);
if (formatted.includes("\n")) {
return `<pre style="white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;margin:0">${escaped}</pre>`;
}
return formatted.length > 120 ?
`<span style="overflow-wrap:anywhere;word-break:break-word">${escaped}</span>` :
escaped;
};
const row = (k, v) => `<tr><td>${esc(String(k))}</td><td>${renderValue(v)}</td></tr>`;
const section = (title) => `<tr><th colspan="2" style="background:#e9ecef;text-align:center">${esc(title)}</th></tr>`;
const objRows = (obj, filter = (v) => v !== null) => {
for (const [k, v] of Object.entries(obj)) {
if (filter(v)) html += row(k, v);
@ -102,8 +120,79 @@ class ExtractAudioMetadata extends Operation {
html += section(title);
for (const item of arr) html += fmt(item);
};
const stripRoot = (path) => path.replace(/^\$\.?/, "");
const fieldKey = (prefix, path) => {
const fieldPath = stripRoot(path);
return fieldPath ? `${prefix}.${fieldPath}` : prefix;
};
const summarizeGeob = (geob) => {
const summary = {
mimeType: geob.mimeType || null,
filename: geob.filename || null,
description: geob.description || null,
object_bytes: geob.object_bytes ?? 0,
};
let html = `<table class="table table-hover table-sm table-bordered table-nonfluid">\n`;
if (geob.object_json !== undefined) {
summary.object_json = geob.object_json;
} else if (geob.object_jumbf) {
summary.object_jumbf = geob.object_jumbf;
} else if (geob.object_text) {
summary.object_text = geob.object_text;
if (geob.object_text_truncated) summary.object_text_truncated = true;
} else if (geob.object_hex_preview) {
summary.object_hex_preview = geob.object_hex_preview;
}
if (geob.object_truncated) summary.object_truncated = true;
return summary;
};
const summarizeJumbfBox = (box) => {
const desc = box.description || {};
const payload = box.payload_type ?
`${String(box.payload_type).toUpperCase()} ${box.decoded ? "decoded" : "not decoded"}` :
box.data_hex_preview ? "binary preview" : null;
return [
box.type_name || box.type,
desc.content_type ? `content: ${desc.content_type}` : null,
desc.content_type_code ? `code: ${desc.content_type_code}` : null,
`${(box.size || 0).toLocaleString()} bytes`,
`offset: ${box.offset ?? "unknown"}`,
box.payload_bytes !== undefined ? `payload: ${(box.payload_bytes || 0).toLocaleString()} bytes` : null,
box.bytes_read !== undefined ? `read: ${(box.bytes_read || 0).toLocaleString()} bytes` : null,
box.trailing_bytes ? `trailing: ${(box.trailing_bytes || 0).toLocaleString()} bytes` : null,
box.warning ? `warning: ${box.warning}` : null,
payload,
`path: ${box.path}`,
].filter(Boolean).join(" | ");
};
const collectJumbfBoxes = (boxes, depth = 0, rows = []) => {
for (const box of boxes || []) {
rows.push({ depth, box });
if (box.children?.length) collectJumbfBoxes(box.children, depth + 1, rows);
}
return rows;
};
const flattenFields = (value, path = "", rows = []) => {
if (value === null || value === undefined || typeof value !== "object") {
rows.push({ path: path || "value", value });
return rows;
}
if (Array.isArray(value)) {
if (!value.length) rows.push({ path: path || "value", value: [] });
value.forEach((item, i) => flattenFields(item, `${path || "value"}[${i}]`, rows));
return rows;
}
const entries = Object.entries(value);
if (!entries.length) rows.push({ path: path || "value", value: {} });
for (const [key, child] of entries) flattenFields(child, path ? `${path}.${key}` : key, rows);
return rows;
};
let html = `<table class="table table-hover table-sm table-bordered" style="table-layout:fixed;width:100%;border-collapse:collapse;margin:0">`;
html += `<colgroup><col style="width:28%"><col style="width:72%"></colgroup>`;
html += section("Artifact");
html += row("Filename", data.artifact?.filename || "(none)");
@ -115,6 +204,7 @@ class ExtractAudioMetadata extends Operation {
html += section("Detections");
html += row("Metadata systems", (data.detections?.metadata_systems || []).join(", ") || "None");
html += row("Provenance systems", (data.detections?.provenance_systems || []).join(", ") || "None");
html += row("Metadata sources", (data.detections?.metadata_sources || []).join(", ") || "None");
const common = data.tags?.common || {};
html += section("Common Tags");
@ -126,10 +216,33 @@ class ExtractAudioMetadata extends Operation {
html += row("(none)", "No common tags found");
}
listSection(data.tags?.raw?.id3v2?.frames, "ID3v2 Frames", (f) => {
const val = typeof f.decoded === "object" ? JSON.stringify(f.decoded) : (f.decoded ?? `(${f.size} bytes)`);
const id3v2Frames = data.tags?.raw?.id3v2?.frames || [];
const geobFrames = id3v2Frames.filter((f) => f.geob);
const displayId3v2Frames = id3v2Frames.filter((f) => !isC2paCarrierFrame(f));
const displayGeobFrames = geobFrames.filter((f) => !isC2paCarrierFrame(f));
objSection(data.tags?.raw?.id3v2?.header, "ID3v2 Header");
listSection(displayId3v2Frames, "ID3v2 Frames", (f) => {
const val = f.geob ? summarizeGeob(f.geob) : f.decoded ?? `(${f.size} bytes)`;
return row(f.id + (f.description ? ` \u2014 ${f.description}` : ""), val);
});
listSection(displayGeobFrames, "ID3v2 GEOB Objects", (f) =>
row(f.geob.filename || f.geob.description || f.geob.mimeType || f.id, summarizeGeob(f.geob)));
for (const f of displayGeobFrames.filter((frame) => frame.geob?.object_json !== undefined)) {
html += section("ID3v2 GEOB Decoded Fields");
const prefix = f.geob.filename || f.geob.description || f.id;
for (const field of flattenFields(f.geob.object_json))
html += row(fieldKey(prefix, field.path), field.value);
}
listSection(data.tags?.raw?.id3v2?.apic, "ID3v2 Attached Pictures", (f) =>
row(f.description || f.picture_type_name || f.mime_type || "(picture)", f));
listSection(data.tags?.raw?.id3v2?.priv, "ID3v2 Private Frames", (f) => row(f.owner_identifier || "(no owner)", f));
listSection(data.tags?.raw?.id3v2?.ufid, "ID3v2 Unique File IDs", (f) => row(f.owner_identifier || "(no owner)", f));
listSection(data.tags?.raw?.id3v2?.wxxx, "ID3v2 User URLs", (f) => row(f.description || "(no description)", f.url));
listSection(data.tags?.raw?.id3v2?.urls, "ID3v2 URL Frames", (f) =>
row(f.frame_id + (f.description ? ` \u2014 ${f.description}` : ""), f.url));
listSection(data.tags?.raw?.id3v2?.uslt, "ID3v2 Lyrics/Text", (f) => row(f.description || f.language || "(lyrics)", f));
listSection(data.tags?.raw?.id3v2?.popm, "ID3v2 Popularimeters", (f) => row(f.email || "(no email)", f));
objSection(data.tags?.raw?.id3v1, "ID3v1", (v) => !!v);
listSection(data.tags?.raw?.apev2?.items, "APEv2 Tags", (i) => row(i.key, i.value));
@ -147,22 +260,60 @@ class ExtractAudioMetadata extends Operation {
if (data.tags?.raw?.mp4?.top_level_atoms?.length) {
html += section("MP4 Top-Level Atoms");
const atoms = data.tags.raw.mp4.top_level_atoms;
for (const a of atoms.slice(0, 50)) html += row(a.type, `${a.size} bytes @ offset ${a.offset}`);
for (const a of atoms.slice(0, 50)) {
const truncation = a.truncated ? ` (truncated; ${a.available_size} bytes available)` : "";
html += row(a.type, `${a.size} bytes @ offset ${a.offset}${truncation}`);
}
if (atoms.length > 50) html += row("...", `${atoms.length - 50} more atoms`);
}
listSection(data.tags?.raw?.mp4?.ilst_items, "MP4 Metadata Items", (i) => row(i.key, i));
listSection(data.tags?.raw?.aiff?.chunks, "AIFF Chunks", (c) => row(c.id, c.value));
objSection(data.tags?.raw?.aac, "AAC ADTS");
objSection(data.tags?.raw?.ac3, "AC3 (Dolby Digital)");
objSection(data.tags?.raw?.asf?.content_description, "ASF Content Description", (v) => !!v);
listSection(data.tags?.raw?.asf?.extended_content, "ASF Extended Content", (d) => row(d.name, d.value));
listSection(data.embedded, "Embedded Objects", (e) => row(e.id, `${e.content_type || "unknown"} \u2014 ${(e.byte_length ?? 0).toLocaleString()} bytes`));
listSection(data.embedded, "Embedded Objects", (e) =>
row(e.id, `${e.source || "unknown"} | ${e.content_type || "unknown"} | ${(e.byte_length ?? 0).toLocaleString()} bytes${e.description ? ` | ${e.description}` : ""}`));
if (data.provenance?.c2pa?.present) {
if (data.metadata_sources?.elevenlabs?.present) {
html += section("ElevenLabs Metadata Source");
for (const entry of (data.metadata_sources.elevenlabs.entries || []))
html += row(entry.source, entry.metadata);
}
if (data.provenance?.c2pa) {
html += section("C2PA Provenance");
html += row("Present", "Yes");
for (const emb of (data.provenance.c2pa.embedding || []))
html += row("Carrier", `${emb.carrier} \u2014 ${(emb.byte_length ?? 0).toLocaleString()} bytes`);
if (data.provenance.c2pa.carrier) {
for (const [key, value] of Object.entries(data.provenance.c2pa.carrier))
html += row(`Carrier ${key}`, value);
}
if (data.provenance.c2pa.manifest_store?.active_manifest_urn)
html += row("Active manifest", data.provenance.c2pa.manifest_store.active_manifest_urn);
if (data.provenance.c2pa.jumbf) {
const jumbf = data.provenance.c2pa.jumbf;
html += section("C2PA/JUMBF Structure");
html += row("Summary", [
`format: ${jumbf.format}`,
`${(jumbf.box_count || 0).toLocaleString()} boxes`,
`labels: ${(jumbf.labels || []).join(" > ") || "none"}`,
jumbf.truncated ? "truncated" : "complete",
].join(" | "));
for (const item of collectJumbfBoxes(jumbf.boxes)) {
const label = item.box.description?.label || item.box.type || "box";
html += row(`${"> ".repeat(item.depth)}${label}`, summarizeJumbfBox(item.box));
}
}
if (data.provenance.c2pa.assertions?.length) {
html += row("Assertions", data.provenance.c2pa.assertions.length);
for (const assertion of data.provenance.c2pa.assertions) {
html += section(`C2PA Assertion: ${assertion.label || assertion.type || "unknown"}`);
html += row("Type", assertion.type);
html += row("Source", assertion.source);
for (const field of flattenFields(assertion.value))
html += row(field.path, field.value);
}
}
}
listSection(data.errors, "Errors", (e) => row(e.stage, e.message));
@ -172,4 +323,69 @@ class ExtractAudioMetadata extends Operation {
}
}
/** Removes empty/default placeholders so output contains extracted metadata only. */
function compactReport(report) {
if (report.provenance?.c2pa && !report.provenance.c2pa.present) {
delete report.provenance.c2pa;
} else if (report.provenance?.c2pa) {
delete report.provenance.c2pa.present;
}
compactId3v2(report);
return pruneEmpty(report) || {};
}
/** Compacts parser diagnostics and carrier-only C2PA frames in final ID3v2 output. */
function compactId3v2(report) {
const id3v2 = report.tags?.raw?.id3v2;
if (!id3v2) return;
if (id3v2.header && !id3v2.header.extended_header) delete id3v2.header;
if (!Array.isArray(id3v2.frames)) return;
id3v2.frames = id3v2.frames
.map((frame) => {
const compact = { ...frame };
if (isTypedId3v2Frame(compact)) delete compact.decoded;
if (isC2paCarrierFrame(compact)) {
compact.geob = {
mimeType: compact.geob?.mimeType || null,
filename: compact.geob?.filename || null,
description: compact.geob?.description || null,
object_bytes: compact.geob?.object_bytes ?? null,
};
}
return compact;
});
}
/** Returns true when the frame is already represented in a typed ID3v2 section. */
function isTypedId3v2Frame(frame) {
return /^(APIC|PIC|PRIV|UFID|UFI|WXXX|WXX|USLT|ULT|POPM|POP)$/i.test(frame.id) ||
(frame.id?.[0] === "W" && frame.id !== "WXX" && frame.id !== "WXXX");
}
/** Returns true for raw carrier frames whose decoded metadata is emitted under provenance.c2pa. */
function isC2paCarrierFrame(frame) {
const mimeType = frame.geob?.mimeType || "";
return !!frame.geob?.object_jumbf || /c2pa|jumbf/i.test(mimeType);
}
/** Recursively removes null, undefined, empty arrays, and empty objects. */
function pruneEmpty(value) {
if (value === null || value === undefined) return undefined;
if (Array.isArray(value)) {
const items = value.map((item) => pruneEmpty(item)).filter((item) => item !== undefined);
return items.length ? items : undefined;
}
if (typeof value === "object") {
const result = {};
for (const [key, child] of Object.entries(value)) {
const pruned = pruneEmpty(child);
if (pruned !== undefined) result[key] = pruned;
}
return Object.keys(result).length ? result : undefined;
}
return value;
}
export default ExtractAudioMetadata;

View File

@ -12,6 +12,187 @@ import {
M4A_HEX, AIFF_HEX
} from "../../samples/Audio.mjs";
const asciiBytes = (s) => Array.from(s, (ch) => ch.charCodeAt(0));
const utf16beBytes = (s) => Array.from(s, (ch) => {
const code = ch.charCodeAt(0);
return [code >> 8, code & 0xff];
}).flat();
const bytesToHex = (bytes) => bytes.map((x) => x.toString(16).padStart(2, "0")).join("");
const synchsafe = (n) => [(n >> 21) & 0x7f, (n >> 14) & 0x7f, (n >> 7) & 0x7f, n & 0x7f];
const id3v23Frame = (id, data) => [
...asciiBytes(id),
(data.length >>> 24) & 0xff, (data.length >>> 16) & 0xff, (data.length >>> 8) & 0xff, data.length & 0xff,
0x00, 0x00,
...data,
];
const id3v23Tag = (frames, flags = 0, prefix = []) => {
const body = [...prefix, ...frames.flat()];
return bytesToHex([...asciiBytes("ID3"), 0x03, 0x00, flags, ...synchsafe(body.length), ...body]);
};
const id3v22Frame = (id, data) => [
...asciiBytes(id),
(data.length >>> 16) & 0xff, (data.length >>> 8) & 0xff, data.length & 0xff,
...data,
];
const id3v22Tag = (frames) => {
const body = frames.flat();
return bytesToHex([...asciiBytes("ID3"), 0x02, 0x00, 0x00, ...synchsafe(body.length), ...body]);
};
const be32 = (n) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
const le32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, (n >>> 24) & 0xff];
const mp4Atom = (type, payload) => [...be32(payload.length + 8), ...asciiBytes(type), ...payload];
const mp4DataAtomTyped = (dataType, payload) => mp4Atom("data", [
...be32(dataType),
0x00, 0x00, 0x00, 0x00,
...payload,
]);
const mp4DataAtom = (value) => mp4DataAtomTyped(1, asciiBytes(value));
const mp4TextChild = (type, value) => mp4Atom(type, [0x00, 0x00, 0x00, 0x00, ...asciiBytes(value)]);
const vorbisCommentBytes = (vendor, comments) => [
...le32(vendor.length), ...asciiBytes(vendor),
...le32(comments.length),
...comments.flatMap((comment) => [...le32(comment.length), ...asciiBytes(comment)]),
];
const cborText = (s) => s.length < 24 ? [0x60 + s.length, ...asciiBytes(s)] : [0x78, s.length, ...asciiBytes(s)];
const cborMap = (entries) => [
0xa0 + entries.length,
...entries.flatMap(([key, value]) => [...cborText(key), ...(typeof value === "string" ? cborText(value) : value)]),
];
const c2paContentTypeUuid = (code) => [...asciiBytes(code), 0x00, 0x11, 0x00, 0x10, 0x80, 0x00, 0x00, 0xaa, 0x00, 0x38, 0x9b, 0x71];
const jumbfDescription = (label, contentType = "c2pa") => mp4Atom("jumd", [...c2paContentTypeUuid(contentType), 0x03, ...asciiBytes(label), 0x00]);
const jumbfSuperbox = (label, children, contentType = "c2pa") => mp4Atom("jumb", [...jumbfDescription(label, contentType), ...children.flat()]);
const ELEVENLABS_METADATA = JSON.stringify({
provider: "ElevenLabs",
"model_id": "eleven_multilingual_v2",
"voice_id": "Rachel",
});
const ELEVENLABS_MP3_HEX = id3v23Tag([
id3v23Frame("TXXX", [0x03, ...asciiBytes("elevenlabs_metadata"), 0x00, ...asciiBytes(ELEVENLABS_METADATA)]),
id3v23Frame("PRIV", [...asciiBytes("com.elevenlabs.metadata"), 0x00, ...asciiBytes(ELEVENLABS_METADATA)]),
]);
const ENCODED_ELEVENLABS_BASE64 = "eyJwcm92aWRlciI6IkVsZXZlbkxhYnMiLCJ2b2ljZV9pZCI6IlJhY2hlbCJ9";
const ENCODED_ELEVENLABS_HEX = "7b2270726f7669646572223a22456c6576656e4c616273222c22766f6963655f6964223a2252616368656c227d";
const ENCODED_ELEVENLABS_MP3_HEX = id3v23Tag([
id3v23Frame("TXXX", [0x03, ...asciiBytes("base64_metadata"), 0x00, ...asciiBytes(ENCODED_ELEVENLABS_BASE64)]),
id3v23Frame("TXXX", [0x03, ...asciiBytes("hex_metadata"), 0x00, ...asciiBytes(ENCODED_ELEVENLABS_HEX)]),
]);
const STANDARD_TEXT_ELEVENLABS_MP3_HEX = id3v23Tag([
id3v23Frame("TIT2", [0x03, ...asciiBytes("ElevenLabs Standard Text")]),
]);
const GEOB_ELEVENLABS_MP3_HEX = id3v23Tag([
id3v23Frame("GEOB", [
0x03, ...asciiBytes("application/json"), 0x00,
...asciiBytes("metadata.json"), 0x00,
...asciiBytes("ElevenLabs object"), 0x00,
...asciiBytes(ELEVENLABS_METADATA),
]),
]);
const C2PA_JUMBF_GEOB_MP3_HEX = id3v23Tag([
id3v23Frame("GEOB", [
0x03, ...asciiBytes("application/x-c2pa-manifest-store"), 0x00,
...asciiBytes("c2pa"), 0x00,
...asciiBytes("c2pa manifest store"), 0x00,
...jumbfSuperbox("c2pa", [
jumbfSuperbox("urn:c2pa:test-manifest", [
jumbfSuperbox("c2pa.assertions", [
jumbfSuperbox("c2pa.actions.v2", [
mp4Atom("cbor", cborMap([
["softwareAgent", "ElevenLabs"],
["digitalSourceType", "trainedAlgorithmicMedia"],
])),
], "cbor"),
jumbfSuperbox("stds.schema-org.CreativeWork", [
mp4Atom("json", asciiBytes(JSON.stringify({ "claim_generator": "ElevenLabs Test Generator" }))),
], "json"),
]),
]),
]),
]),
]);
const RICH_ID3_MP3_HEX = id3v23Tag([
id3v23Frame("APIC", [0x03, ...asciiBytes("image/jpeg"), 0x00, 0x03, ...asciiBytes("Front cover"), 0x00, 0xff, 0xd8, 0xff, 0xdb, 0x00]),
id3v23Frame("WOAR", asciiBytes("https://artist.example/profile")),
id3v23Frame("USLT", [0x03, ...asciiBytes("eng"), ...asciiBytes("Transcript"), 0x00, ...asciiBytes("Hello lyric line")]),
id3v23Frame("POPM", [...asciiBytes("user@example.com"), 0x00, 196, 0x00, 0x00, 0x00, 0x05]),
]);
const EXTENDED_HEADER_MP3_HEX = id3v23Tag([
id3v23Frame("TIT2", [0x03, ...asciiBytes("Extended Header Song")]),
], 0x40, [
...be32(6),
0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
]);
const ID3V22_MP3_HEX = id3v22Tag([
id3v22Frame("TT2", [0x03, ...asciiBytes("Legacy Song")]),
id3v22Frame("WAR", asciiBytes("https://legacy.example/artist")),
id3v22Frame("PIC", [0x03, ...asciiBytes("JPG"), 0x03, ...asciiBytes("Legacy cover"), 0x00, 0xff, 0xd8, 0xff, 0xdb]),
]);
const ELEVENLABS_OGG_HEX = bytesToHex([
...asciiBytes("OggS"), 0x00, 0x00, 0x00, 0x00,
...asciiBytes("OpusTags"),
...vorbisCommentBytes("test-vendor", ["PROVIDER=ElevenLabs", "MODEL=eleven_multilingual_v2"]),
]);
const NESTED_M4A_HEX = bytesToHex([
...mp4Atom("ftyp", [...asciiBytes("M4A "), 0x00, 0x00, 0x02, 0x00, ...asciiBytes("M4A "), ...asciiBytes("isom")]),
...mp4Atom("moov", [
...mp4Atom("udta", [
...mp4Atom("meta", [
0x00, 0x00, 0x00, 0x00,
...mp4Atom("ilst", [
...mp4Atom("\xa9nam", [...mp4DataAtom("Nested Song")]),
...mp4Atom("\xa9ART", [...mp4DataAtom("Nested Artist")]),
]),
]),
]),
]),
]);
const COVER_M4A_HEX = bytesToHex([
...mp4Atom("ftyp", [...asciiBytes("M4A "), 0x00, 0x00, 0x02, 0x00, ...asciiBytes("M4A "), ...asciiBytes("isom")]),
...mp4Atom("moov", [
...mp4Atom("udta", [
...mp4Atom("meta", [
0x00, 0x00, 0x00, 0x00,
...mp4Atom("ilst", [
...mp4Atom("covr", [...mp4DataAtomTyped(13, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])]),
]),
]),
]),
]),
]);
const UTF16_M4A_HEX = bytesToHex([
...mp4Atom("ftyp", [...asciiBytes("M4A "), 0x00, 0x00, 0x02, 0x00, ...asciiBytes("M4A "), ...asciiBytes("isom")]),
...mp4Atom("moov", [
...mp4Atom("udta", [
...mp4Atom("meta", [
0x00, 0x00, 0x00, 0x00,
...mp4Atom("ilst", [
...mp4Atom("\xa9nam", [...mp4DataAtomTyped(2, utf16beBytes("UTF16 Song"))]),
]),
]),
]),
]),
]);
const RICH_M4A_HEX = bytesToHex([
...mp4Atom("ftyp", [...asciiBytes("M4A "), 0x00, 0x00, 0x02, 0x00, ...asciiBytes("M4A "), ...asciiBytes("isom")]),
...mp4Atom("moov", [
...mp4Atom("udta", [
...mp4Atom("meta", [
0x00, 0x00, 0x00, 0x00,
...mp4Atom("ilst", [
...mp4Atom("trkn", [...mp4DataAtomTyped(0, [0x00, 0x00, 0x00, 0x02, 0x00, 0x09, 0x00, 0x00])]),
...mp4Atom("tmpo", [...mp4DataAtomTyped(21, [0x78])]),
...mp4Atom("covr", [...mp4DataAtomTyped(14, [0x89, 0x50, 0x4e, 0x47, 0x00, 0x00, 0x00, 0x00])]),
...mp4Atom("----", [
...mp4TextChild("mean", "com.apple.iTunes"),
...mp4TextChild("name", "ELEVENLABS_METADATA"),
...mp4DataAtom(ELEVENLABS_METADATA),
]),
]),
]),
]),
]),
]);
TestRegister.addTests([
// ---- MP3 ----
{
@ -50,6 +231,87 @@ TestRegister.addTests([
{ op: "Extract Audio Metadata", args: ["test.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 ElevenLabs private metadata",
input: ELEVENLABS_MP3_HEX,
expectedMatch: /Metadata sources<\/td><td>elevenlabs<\/td>.*ID3v2 Private Frames.*com\.elevenlabs\.metadata.*model_id.*eleven_multilingual_v2.*ElevenLabs Metadata Source.*id3v2:TXXX.*value_json.*id3v2:PRIV.*data_json.*voice_id.*Rachel/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["elevenlabs.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 encoded hex/base64 text metadata",
input: ENCODED_ELEVENLABS_MP3_HEX,
expectedMatch: /Metadata sources<\/td><td>elevenlabs<\/td>.*base64_metadata.*value_decoded.*encoding.*base64.*text.*provider.*ElevenLabs.*json.*voice_id.*Rachel.*hex_metadata.*value_decoded.*encoding.*hex.*text.*provider.*ElevenLabs.*json.*voice_id.*Rachel/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["encoded-elevenlabs.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 ElevenLabs standard text metadata",
input: STANDARD_TEXT_ELEVENLABS_MP3_HEX,
expectedMatch: /Metadata sources<\/td><td>elevenlabs<\/td>.*Title<\/td><td>ElevenLabs Standard Text<\/td>.*ElevenLabs Metadata Source.*id3v2:TIT2.*ElevenLabs Standard Text/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["elevenlabs-text.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 GEOB JSON metadata",
input: GEOB_ELEVENLABS_MP3_HEX,
expectedMatch: /Metadata sources<\/td><td>elevenlabs<\/td>.*ID3v2 Frames.*GEOB.*object_bytes.*object_json.*model_id.*eleven_multilingual_v2.*ID3v2 GEOB Objects.*metadata\.json.*ElevenLabs Metadata Source.*id3v2:GEOB.*voice_id.*Rachel/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["geob-elevenlabs.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 GEOB C2PA JUMBF metadata",
input: C2PA_JUMBF_GEOB_MP3_HEX,
expectedMatch: /^(?=[\s\S]*Provenance systems<\/td><td>c2pa<\/td>)(?=[\s\S]*Metadata sources<\/td><td>None<\/td>)(?=[\s\S]*C2PA Provenance)(?=[\s\S]*Carrier source<\/td><td>id3v2:GEOB<\/td>)(?=[\s\S]*Carrier content_type<\/td><td>application\/x-c2pa-manifest-store<\/td>)(?=[\s\S]*Carrier filename<\/td><td>c2pa<\/td>)(?=[\s\S]*Carrier description<\/td><td>c2pa manifest store<\/td>)(?=[\s\S]*Carrier byte_length<\/td><td>\d+<\/td>)(?=[\s\S]*Active manifest<\/td><td>urn:c2pa:test-manifest<\/td>)(?=[\s\S]*C2PA\/JUMBF Structure)(?=[\s\S]*JUMBF superbox)(?=[\s\S]*Assertions<\/td><td>2<\/td>)(?=[\s\S]*C2PA Assertion: c2pa\.actions\.v2)(?=[\s\S]*Source<\/td><td>jumb\[0\]\.jumb\[1\]\.jumb\[1\]\.jumb\[1\]\.cbor\[1\]<\/td>)(?=[\s\S]*softwareAgent<\/td><td>ElevenLabs<\/td>)(?=[\s\S]*digitalSourceType<\/td><td>trainedAlgorithmicMedia<\/td>)(?=[\s\S]*C2PA Assertion: stds\.schema-org\.CreativeWork)(?=[\s\S]*claim_generator<\/td><td>ElevenLabs Test Generator<\/td>)(?=[\s\S]*urn:c2pa:test-manifest)(?=[\s\S]*cbor)[\s\S]*$/,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["c2pa-jumbf.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 C2PA output avoids decoded payload duplication",
input: C2PA_JUMBF_GEOB_MP3_HEX,
unexpectedMatch: /C2PA\/JUMBF Summary|ID3v2 GEOB Objects|GEOB \u2014 General encapsulated object|object_jumbf|decoded_payloads|object_hex_preview/,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["c2pa-jumbf.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 structured ID3v2 metadata families",
input: RICH_ID3_MP3_HEX,
expectedMatch: /ID3v2 Attached Pictures.*image\/jpeg.*Cover \(front\).*image_length.*5.*ID3v2 URL Frames.*WOAR.*https:\/\/artist\.example\/profile.*ID3v2 Lyrics\/Text.*language.*eng.*Hello lyric line.*text_bytes.*16.*ID3v2 Popularimeters.*user@example\.com.*rating.*196.*counter.*5.*Embedded Objects.*id3v2:APIC.*image\/jpeg/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["rich-id3.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 ID3v2 extended header",
input: EXTENDED_HEADER_MP3_HEX,
expectedMatch: /Title<\/td><td>Extended Header Song<\/td>.*ID3v2 Header.*extended_header.*size.*6.*ID3v2 Frames.*TIT2.*Extended Header Song/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["extended-header.mp3", 524288] }
]
},
{
name: "Extract Audio Metadata: MP3 ID3v2.2 legacy metadata",
input: ID3V22_MP3_HEX,
expectedMatch: /Title<\/td><td>Legacy Song<\/td>.*ID3v2 Frames.*TT2.*Legacy Song.*PIC.*Attached picture.*ID3v2 Attached Pictures.*Legacy cover.*image\/jpeg.*ID3v2 URL Frames.*WAR.*https:\/\/legacy\.example\/artist.*Embedded Objects.*id3v2:PIC/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["legacy-id3v22.mp3", 524288] }
]
},
// ---- WAV ----
{
@ -168,6 +430,15 @@ TestRegister.addTests([
{ op: "Extract Audio Metadata", args: ["test.ogg", 524288] }
]
},
{
name: "Extract Audio Metadata: OGG ElevenLabs Vorbis provenance",
input: ELEVENLABS_OGG_HEX,
expectedMatch: /Metadata sources<\/td><td>elevenlabs<\/td>.*Vorbis Comments.*PROVIDER<\/td><td>ElevenLabs<\/td>.*MODEL<\/td><td>eleven_multilingual_v2<\/td>.*ElevenLabs Metadata Source.*ogg:VORBIS_COMMENT/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["elevenlabs.ogg", 524288] }
]
},
// ---- Opus ----
{
@ -255,6 +526,42 @@ TestRegister.addTests([
{ op: "Extract Audio Metadata", args: ["test.m4a", 524288] }
]
},
{
name: "Extract Audio Metadata: M4A nested ilst metadata",
input: NESTED_M4A_HEX,
expectedMatch: /Metadata systems<\/td><td>mp4_atoms, mp4_ilst<\/td>.*Title<\/td><td>Nested Song<\/td>.*Artist<\/td><td>Nested Artist<\/td>.*MP4 Metadata Items.*\xA9nam.*Nested Song/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["nested.m4a", 524288] }
]
},
{
name: "Extract Audio Metadata: M4A cover art metadata",
input: COVER_M4A_HEX,
expectedMatch: /MP4 Metadata Items.*covr.*JPEG image.*Embedded Objects.*mp4:ilst:covr.*image\/jpeg.*6 bytes/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["cover.m4a", 524288] }
]
},
{
name: "Extract Audio Metadata: M4A UTF-16 ilst text metadata",
input: UTF16_M4A_HEX,
expectedMatch: /Title<\/td><td>UTF16 Song<\/td>.*MP4 Metadata Items.*UTF-16 text.*UTF16 Song/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["utf16.m4a", 524288] }
]
},
{
name: "Extract Audio Metadata: M4A rich ilst metadata",
input: RICH_M4A_HEX,
expectedMatch: /Metadata sources<\/td><td>elevenlabs<\/td>.*Track<\/td><td>2\/9<\/td>.*MP4 Metadata Items.*trkn.*2\/9.*tmpo.*Signed integer.*120.*covr.*PNG image.*ELEVENLABS_METADATA.*com\.apple\.iTunes.*Embedded Objects.*mp4:ilst:covr.*image\/png.*8 bytes.*ElevenLabs Metadata Source.*mp4:ilst:ELEVENLABS_METADATA/s,
recipeConfig: [
{ op: "From Hex", args: ["None"] },
{ op: "Extract Audio Metadata", args: ["rich.m4a", 524288] }
]
},
// ---- AIFF ----
{