Improve audio metadata extraction
This commit is contained in:
parent
a492a3c034
commit
4b3c612707
@ -13,32 +13,24 @@ import { ascii4, indexOfAscii } from "./AudioBytes.mjs";
|
|||||||
/** Builds the empty report skeleton ready for a format parser to populate. */
|
/** Builds the empty report skeleton ready for a format parser to populate. */
|
||||||
export function makeEmptyReport(filename, byteLength, container) {
|
export function makeEmptyReport(filename, byteLength, container) {
|
||||||
return {
|
return {
|
||||||
schema_version: "audio-meta-1.0",
|
schema_version: "audio-meta-1.2",
|
||||||
artifact: {
|
artifact: {
|
||||||
filename,
|
filename,
|
||||||
byte_length: byteLength,
|
byte_length: byteLength,
|
||||||
container: { type: container.type, brand: container.brand || null, mime: container.mime || null },
|
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: {
|
tags: {
|
||||||
common: {
|
common: {},
|
||||||
title: null, artist: null, album: null, date: null, track: null,
|
|
||||||
genre: null, comment: null, composer: null, copyright: null, language: null,
|
|
||||||
},
|
|
||||||
raw: {},
|
raw: {},
|
||||||
},
|
},
|
||||||
embedded: [],
|
embedded: [],
|
||||||
|
metadata_sources: {},
|
||||||
provenance: {
|
provenance: {
|
||||||
c2pa: {
|
c2pa: {
|
||||||
present: false,
|
present: false,
|
||||||
embedding: [],
|
manifest_store: {},
|
||||||
manifest_store: { active_manifest_urn: null, instance_id: null, claim_generator: null },
|
|
||||||
assertions: [],
|
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: [],
|
errors: [],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,8 @@
|
|||||||
* @license Apache-2.0
|
* @license Apache-2.0
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
|
||||||
import Operation from "../Operation.mjs";
|
import Operation from "../Operation.mjs";
|
||||||
import OperationError from "../errors/OperationError.mjs";
|
import OperationError from "../errors/OperationError.mjs";
|
||||||
import Utils from "../Utils.mjs";
|
import Utils from "../Utils.mjs";
|
||||||
@ -55,7 +57,7 @@ class ExtractAudioMetadata extends Operation {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsers = {
|
const parsers = {
|
||||||
mp3: () => parseMp3(bytes, report),
|
mp3: () => parseMp3(bytes, report, maxTextBytes),
|
||||||
wav: () => parseRiffWave(bytes, report, maxTextBytes),
|
wav: () => parseRiffWave(bytes, report, maxTextBytes),
|
||||||
bw64: () => parseRiffWave(bytes, report, maxTextBytes),
|
bw64: () => parseRiffWave(bytes, report, maxTextBytes),
|
||||||
flac: () => parseFlac(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) });
|
report.errors.push({ stage: "parse", message: String(e?.message || e) });
|
||||||
}
|
}
|
||||||
|
|
||||||
return report;
|
return compactReport(report);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Renders the extracted metadata as an HTML table. */
|
/** 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);
|
if (!data || typeof data !== "object") return JSON.stringify(data, null, 4);
|
||||||
|
|
||||||
const esc = Utils.escapeHtml;
|
const esc = Utils.escapeHtml;
|
||||||
const row = (k, v) => `<tr><td>${esc(String(k))}</td><td>${esc(String(v ?? ""))}</td></tr>\n`;
|
const formatValue = (v) => {
|
||||||
const section = (title) => `<tr><th colspan="2" style="background:#e9ecef;text-align:center">${esc(title)}</th></tr>\n`;
|
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) => {
|
const objRows = (obj, filter = (v) => v !== null) => {
|
||||||
for (const [k, v] of Object.entries(obj)) {
|
for (const [k, v] of Object.entries(obj)) {
|
||||||
if (filter(v)) html += row(k, v);
|
if (filter(v)) html += row(k, v);
|
||||||
@ -102,8 +120,79 @@ class ExtractAudioMetadata extends Operation {
|
|||||||
html += section(title);
|
html += section(title);
|
||||||
for (const item of arr) html += fmt(item);
|
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 += section("Artifact");
|
||||||
html += row("Filename", data.artifact?.filename || "(none)");
|
html += row("Filename", data.artifact?.filename || "(none)");
|
||||||
@ -115,6 +204,7 @@ class ExtractAudioMetadata extends Operation {
|
|||||||
html += section("Detections");
|
html += section("Detections");
|
||||||
html += row("Metadata systems", (data.detections?.metadata_systems || []).join(", ") || "None");
|
html += row("Metadata systems", (data.detections?.metadata_systems || []).join(", ") || "None");
|
||||||
html += row("Provenance systems", (data.detections?.provenance_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 || {};
|
const common = data.tags?.common || {};
|
||||||
html += section("Common Tags");
|
html += section("Common Tags");
|
||||||
@ -126,10 +216,33 @@ class ExtractAudioMetadata extends Operation {
|
|||||||
html += row("(none)", "No common tags found");
|
html += row("(none)", "No common tags found");
|
||||||
}
|
}
|
||||||
|
|
||||||
listSection(data.tags?.raw?.id3v2?.frames, "ID3v2 Frames", (f) => {
|
const id3v2Frames = data.tags?.raw?.id3v2?.frames || [];
|
||||||
const val = typeof f.decoded === "object" ? JSON.stringify(f.decoded) : (f.decoded ?? `(${f.size} bytes)`);
|
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);
|
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);
|
objSection(data.tags?.raw?.id3v1, "ID3v1", (v) => !!v);
|
||||||
listSection(data.tags?.raw?.apev2?.items, "APEv2 Tags", (i) => row(i.key, i.value));
|
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) {
|
if (data.tags?.raw?.mp4?.top_level_atoms?.length) {
|
||||||
html += section("MP4 Top-Level Atoms");
|
html += section("MP4 Top-Level Atoms");
|
||||||
const atoms = data.tags.raw.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`);
|
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));
|
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?.aac, "AAC ADTS");
|
||||||
objSection(data.tags?.raw?.ac3, "AC3 (Dolby Digital)");
|
objSection(data.tags?.raw?.ac3, "AC3 (Dolby Digital)");
|
||||||
objSection(data.tags?.raw?.asf?.content_description, "ASF Content Description", (v) => !!v);
|
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.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 += section("C2PA Provenance");
|
||||||
html += row("Present", "Yes");
|
if (data.provenance.c2pa.carrier) {
|
||||||
for (const emb of (data.provenance.c2pa.embedding || []))
|
for (const [key, value] of Object.entries(data.provenance.c2pa.carrier))
|
||||||
html += row("Carrier", `${emb.carrier} \u2014 ${(emb.byte_length ?? 0).toLocaleString()} bytes`);
|
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));
|
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;
|
export default ExtractAudioMetadata;
|
||||||
|
|||||||
@ -12,6 +12,187 @@ import {
|
|||||||
M4A_HEX, AIFF_HEX
|
M4A_HEX, AIFF_HEX
|
||||||
} from "../../samples/Audio.mjs";
|
} 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([
|
TestRegister.addTests([
|
||||||
// ---- MP3 ----
|
// ---- MP3 ----
|
||||||
{
|
{
|
||||||
@ -50,6 +231,87 @@ TestRegister.addTests([
|
|||||||
{ op: "Extract Audio Metadata", args: ["test.mp3", 524288] }
|
{ 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 ----
|
// ---- WAV ----
|
||||||
{
|
{
|
||||||
@ -168,6 +430,15 @@ TestRegister.addTests([
|
|||||||
{ op: "Extract Audio Metadata", args: ["test.ogg", 524288] }
|
{ 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 ----
|
// ---- Opus ----
|
||||||
{
|
{
|
||||||
@ -255,6 +526,42 @@ TestRegister.addTests([
|
|||||||
{ op: "Extract Audio Metadata", args: ["test.m4a", 524288] }
|
{ 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 ----
|
// ---- AIFF ----
|
||||||
{
|
{
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user