mirror of
https://github.com/gchq/CyberChef.git
synced 2026-03-26 08:41:25 -07:00
Add Extract Audio Metadata operation (#2170)
Co-authored-by: GCHQDeveloper581 <63102987+GCHQDeveloper581@users.noreply.github.com> (minor tweak to wikipedia url)
This commit is contained in:
parent
d9dacf6b8f
commit
f7bbb33084
8 changed files with 1356 additions and 2 deletions
|
|
@ -385,6 +385,7 @@
|
|||
"CSS selector",
|
||||
"Extract EXIF",
|
||||
"Extract ID3",
|
||||
"Extract Audio Metadata",
|
||||
"Extract Files",
|
||||
"RAKE",
|
||||
"Template"
|
||||
|
|
@ -514,7 +515,8 @@
|
|||
"View Bit Plane",
|
||||
"Randomize Colour Palette",
|
||||
"Extract LSB",
|
||||
"ELF Info"
|
||||
"ELF Info",
|
||||
"Extract Audio Metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -547,7 +549,8 @@
|
|||
"Hex Density chart",
|
||||
"Scatter chart",
|
||||
"Series chart",
|
||||
"Heatmap chart"
|
||||
"Heatmap chart",
|
||||
"Extract Audio Metadata"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
103
src/core/lib/AudioBytes.mjs
Normal file
103
src/core/lib/AudioBytes.mjs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
/**
|
||||
* Byte-reading and text-decoding utilities for audio metadata parsing.
|
||||
*
|
||||
* @author d0s1nt [d0s1nt@cyberchefaudio]
|
||||
* @copyright Crown Copyright 2025
|
||||
* @license Apache-2.0
|
||||
*/
|
||||
|
||||
/** @returns {string} 4-byte ASCII at offset, or "" if out of bounds. */
|
||||
export function ascii4(b, off) {
|
||||
if (off + 4 > b.length) return "";
|
||||
return String.fromCharCode(b[off], b[off + 1], b[off + 2], b[off + 3]);
|
||||
}
|
||||
|
||||
/** @returns {number} Byte offset of ASCII needle `s`, or -1. */
|
||||
export function indexOfAscii(b, s, start, end) {
|
||||
const limit = Math.max(0, Math.min(end, b.length) - s.length);
|
||||
for (let i = start; i <= limit; i++) {
|
||||
let ok = true;
|
||||
for (let j = 0; j < s.length; j++) {
|
||||
if (b[i + j] !== s.charCodeAt(j)) {
|
||||
ok = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (ok) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/** @returns {number} Unsigned 32-bit big-endian read. */
|
||||
export function u32be(bytes, off) {
|
||||
return ((bytes[off] << 24) >>> 0) | (bytes[off + 1] << 16) | (bytes[off + 2] << 8) | bytes[off + 3];
|
||||
}
|
||||
|
||||
/** @returns {number} Unsigned 32-bit little-endian read. */
|
||||
export function u32le(bytes, off) {
|
||||
return (bytes[off] | (bytes[off + 1] << 8) | (bytes[off + 2] << 16) | (bytes[off + 3] << 24)) >>> 0;
|
||||
}
|
||||
|
||||
/** @returns {number} Unsigned 16-bit little-endian read. */
|
||||
export function u16le(bytes, off) {
|
||||
return bytes[off] | (bytes[off + 1] << 8);
|
||||
}
|
||||
|
||||
/** @returns {BigInt} Unsigned 64-bit little-endian read. */
|
||||
export function u64le(bytes, off) {
|
||||
return BigInt(u32le(bytes, off)) | (BigInt(u32le(bytes, off + 4)) << 32n);
|
||||
}
|
||||
|
||||
/** @returns {number} Decoded ID3v2 synchsafe integer from four 7-bit bytes. */
|
||||
export function synchsafeToInt(b0, b1, b2, b3) {
|
||||
return ((b0 & 0x7f) << 21) | ((b1 & 0x7f) << 14) | ((b2 & 0x7f) << 7) | (b3 & 0x7f);
|
||||
}
|
||||
|
||||
/** @returns {string} Decoded UTF-16LE byte range, nulls stripped. */
|
||||
export function decodeUtf16LE(b, off, len) {
|
||||
if (len <= 0 || off + len > b.length) return "";
|
||||
try {
|
||||
return new TextDecoder("utf-16le").decode(b.slice(off, off + len)).replace(/\u0000/g, "").trim();
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {{valueBytes: Uint8Array, next: number}} Bytes until null terminator, UTF-16 aware. */
|
||||
export function readNullTerminated(bytes, start, encoding) {
|
||||
const isUtf16 = encoding === 1 || encoding === 2;
|
||||
if (!isUtf16) {
|
||||
let i = start;
|
||||
while (i < bytes.length && bytes[i] !== 0x00) i++;
|
||||
return { valueBytes: bytes.slice(start, i), next: i + 1 };
|
||||
}
|
||||
let i = start;
|
||||
while (i + 1 < bytes.length && !(bytes[i] === 0x00 && bytes[i + 1] === 0x00)) i += 2;
|
||||
return { valueBytes: bytes.slice(start, i), next: i + 2 };
|
||||
}
|
||||
|
||||
const ID3_ENCODINGS = ["iso-8859-1", "utf-16", "utf-16be", "utf-8"];
|
||||
|
||||
/** @returns {string} Text decoded using ID3v2 encoding byte (0=latin1, 1=utf16, 2=utf16be, 3=utf8). */
|
||||
export function decodeText(bytes, encoding) {
|
||||
if (!bytes || bytes.length === 0) return "";
|
||||
try {
|
||||
return new TextDecoder(ID3_ENCODINGS[encoding] || "utf-16").decode(bytes);
|
||||
} catch {
|
||||
return safeUtf8(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {string} UTF-8 decode with replacement (never throws). */
|
||||
export function safeUtf8(bytes) {
|
||||
try {
|
||||
return new TextDecoder("utf-8", { fatal: false }).decode(bytes);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** @returns {string} ISO-8859-1 decode, nulls stripped, trimmed. */
|
||||
export function decodeLatin1Trim(bytes) {
|
||||
return decodeText(bytes, 0).replace(/\u0000/g, "").trim();
|
||||
}
|
||||
82
src/core/lib/AudioMetaSchema.mjs
Normal file
82
src/core/lib/AudioMetaSchema.mjs
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* Report skeleton and container detection for audio metadata extraction.
|
||||
*
|
||||
* @author d0s1nt [d0s1nt@cyberchefaudio]
|
||||
* @copyright Crown Copyright 2025
|
||||
* @license Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
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",
|
||||
artifact: {
|
||||
filename,
|
||||
byte_length: byteLength,
|
||||
container: { type: container.type, brand: container.brand || null, mime: container.mime || null },
|
||||
},
|
||||
detections: { metadata_systems: [], provenance_systems: [] },
|
||||
tags: {
|
||||
common: {
|
||||
title: null, artist: null, album: null, date: null, track: null,
|
||||
genre: null, comment: null, composer: null, copyright: null, language: null,
|
||||
},
|
||||
raw: {},
|
||||
},
|
||||
embedded: [],
|
||||
provenance: {
|
||||
c2pa: {
|
||||
present: false,
|
||||
embedding: [],
|
||||
manifest_store: { active_manifest_urn: null, instance_id: null, claim_generator: null },
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Detects the audio container format from magic bytes. */
|
||||
export function sniffContainer(b) {
|
||||
if (b.length >= 3 && b[0] === 0x49 && b[1] === 0x44 && b[2] === 0x33)
|
||||
return { type: "mp3", mime: "audio/mpeg" };
|
||||
if (b.length >= 2 && b[0] === 0xff && (b[1] & 0xe0) === 0xe0) {
|
||||
if ((b[1] & 0x06) === 0x00) return { type: "aac", mime: "audio/aac" };
|
||||
return { type: "mp3", mime: "audio/mpeg" };
|
||||
}
|
||||
if (b.length >= 8 && b[0] === 0x0b && b[1] === 0x77)
|
||||
return { type: "ac3", mime: "audio/ac3" };
|
||||
if (b.length >= 16 &&
|
||||
b[0] === 0x30 && b[1] === 0x26 && b[2] === 0xb2 && b[3] === 0x75 &&
|
||||
b[4] === 0x8e && b[5] === 0x66 && b[6] === 0xcf && b[7] === 0x11)
|
||||
return { type: "wma", mime: "audio/x-ms-wma" };
|
||||
if (b.length >= 12 && ascii4(b, 0) === "RIFF" && ascii4(b, 8) === "WAVE")
|
||||
return { type: "wav", mime: "audio/wav" };
|
||||
if (b.length >= 12 && ascii4(b, 0) === "BW64" && ascii4(b, 8) === "WAVE")
|
||||
return { type: "bw64", mime: "audio/wav" };
|
||||
if (b.length >= 4 && ascii4(b, 0) === "fLaC")
|
||||
return { type: "flac", mime: "audio/flac" };
|
||||
if (b.length >= 4 && ascii4(b, 0) === "OggS") {
|
||||
const idx = indexOfAscii(b, "OpusHead", 0, Math.min(b.length, 65536));
|
||||
return idx >= 0 ? { type: "opus", mime: "audio/ogg" } : { type: "ogg", mime: "audio/ogg" };
|
||||
}
|
||||
if (b.length >= 12 && ascii4(b, 4) === "ftyp") {
|
||||
const brand = ascii4(b, 8);
|
||||
const isM4A = brand === "M4A " || brand === "M4B " || brand === "M4P ";
|
||||
return { type: isM4A ? "m4a" : "mp4", mime: isM4A ? "audio/mp4" : "video/mp4", brand };
|
||||
}
|
||||
if (b.length >= 12 && ascii4(b, 0) === "FORM") {
|
||||
const formType = ascii4(b, 8);
|
||||
if (formType === "AIFF" || formType === "AIFC") return { type: "aiff", mime: "audio/aiff", brand: formType };
|
||||
}
|
||||
return { type: "unknown", mime: null };
|
||||
}
|
||||
630
src/core/lib/AudioParsers.mjs
Normal file
630
src/core/lib/AudioParsers.mjs
Normal file
|
|
@ -0,0 +1,630 @@
|
|||
/**
|
||||
* Format-specific audio metadata parsers.
|
||||
*
|
||||
* @author d0s1nt [d0s1nt@cyberchefaudio]
|
||||
* @copyright Crown Copyright 2025
|
||||
* @license Apache-2.0
|
||||
*/
|
||||
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
import {
|
||||
ascii4, indexOfAscii,
|
||||
u32be, u32le, u16le, u64le, synchsafeToInt,
|
||||
decodeUtf16LE, readNullTerminated, decodeText,
|
||||
safeUtf8, decodeLatin1Trim,
|
||||
} from "./AudioBytes.mjs";
|
||||
|
||||
/** Parses MP3 metadata: ID3v2 frames, ID3v1 footer, APEv2 tags. */
|
||||
export function parseMp3(b, report) {
|
||||
processId3v2(b, report);
|
||||
processId3v1(b, report);
|
||||
|
||||
const ape = parseApeV2BestEffort(b);
|
||||
if (ape) {
|
||||
report.detections.metadata_systems.push("apev2");
|
||||
report.tags.raw.apev2 = ape;
|
||||
}
|
||||
}
|
||||
|
||||
/** Iterates ID3v2 frames and populates the report. */
|
||||
function processId3v2(b, report) {
|
||||
report.detections.metadata_systems.push("id3v2");
|
||||
|
||||
const id3 = parseId3v2(b);
|
||||
report.tags.raw.id3v2 = id3 ? { header: id3.header, frames: [] } : null;
|
||||
|
||||
if (id3) {
|
||||
for (const f of id3.frames) {
|
||||
const entry = { id: f.id, size: f.size, description: ID3_FRAME_DESCRIPTIONS[f.id] || null };
|
||||
|
||||
if (f.id[0] === "T" && f.id !== "TXXX") {
|
||||
const text = f.data?.length >= 1 ?
|
||||
decodeText(f.data.slice(1), f.data[0]).replace(/\u0000/g, "").trim() :
|
||||
"";
|
||||
entry.decoded = text;
|
||||
if (f.id === "TLEN") {
|
||||
const ms = normalizeTlen(text);
|
||||
if (ms !== null) entry.normalized_ms = ms;
|
||||
}
|
||||
mapCommonId3(report, f.id, text);
|
||||
} else if (f.id === "TXXX") {
|
||||
const txxx = decodeTxxx(f.data);
|
||||
entry.decoded = txxx;
|
||||
if (!report.tags.raw.id3v2.txxx) report.tags.raw.id3v2.txxx = [];
|
||||
report.tags.raw.id3v2.txxx.push(txxx);
|
||||
} else if (f.id === "COMM") {
|
||||
const comm = decodeCommFrame(f.data);
|
||||
entry.decoded = comm;
|
||||
if (comm?.text && !report.tags.common.comment) report.tags.common.comment = comm.text;
|
||||
} else if (f.id === "GEOB") {
|
||||
processGeobFrame(f, entry, report);
|
||||
}
|
||||
|
||||
report.tags.raw.id3v2.frames.push(entry);
|
||||
}
|
||||
} else {
|
||||
report.detections.metadata_systems = report.detections.metadata_systems.filter((x) => x !== "id3v2");
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses GEOB frame contents, populates entry, embedded objects, and C2PA provenance. */
|
||||
function processGeobFrame(f, entry, report) {
|
||||
const d = f.data, enc = d[0];
|
||||
let off = 1;
|
||||
const mime = readNullTerminated(d, off, 0);
|
||||
const mimeType = decodeLatin1Trim(mime.valueBytes);
|
||||
off = mime.next;
|
||||
const file = readNullTerminated(d, off, enc);
|
||||
const filename = decodeText(file.valueBytes, enc).replace(/\u0000/g, "").trim();
|
||||
off = file.next;
|
||||
const desc = readNullTerminated(d, off, enc);
|
||||
const description = decodeText(desc.valueBytes, enc).replace(/\u0000/g, "").trim();
|
||||
off = desc.next;
|
||||
const objLen = d.length - off;
|
||||
|
||||
entry.geob = { mimeType, filename, description, object_bytes: objLen };
|
||||
const geobId = `geob_${report.embedded.filter((x) => x.source === "id3v2:GEOB").length}`;
|
||||
report.embedded.push({
|
||||
id: geobId, source: "id3v2:GEOB",
|
||||
content_type: mimeType || null, byte_length: objLen,
|
||||
description: description || null, filename: filename || null,
|
||||
});
|
||||
|
||||
const mt = (mimeType || "").toLowerCase();
|
||||
if (mt.includes("c2pa") || mt.includes("jumbf") || mt.includes("application/x-c2pa-manifest-store")) {
|
||||
report.provenance.c2pa.present = true;
|
||||
report.provenance.c2pa.embedding.push({
|
||||
carrier: "id3v2:GEOB", content_type: mimeType || null, byte_length: objLen,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Processes the 128-byte ID3v1 footer tag. */
|
||||
function processId3v1(b, report) {
|
||||
const id3v1 = parseId3v1(b);
|
||||
if (!id3v1) return;
|
||||
|
||||
report.detections.metadata_systems.push("id3v1");
|
||||
report.tags.raw.id3v1 = id3v1;
|
||||
mapCommon(report, id3v1, ID3V1_TO_COMMON);
|
||||
}
|
||||
|
||||
/** Parses WAV/BWF/BW64 RIFF chunks: LIST/INFO, bext, iXML, axml, ds64. */
|
||||
export function parseRiffWave(b, report, maxTextBytes) {
|
||||
report.detections.metadata_systems.push("riff_info");
|
||||
|
||||
const chunks = enumerateChunks(b, 12, b.length, 50000);
|
||||
const riff = { chunks: [], info: null, bext: null, ixml: null, axml: null, ds64: null };
|
||||
|
||||
const info = {};
|
||||
for (const c of chunks) {
|
||||
riff.chunks.push({ id: c.id, size: c.size, offset: c.dataOff });
|
||||
processRiffChunk(b, c, riff, info, report, maxTextBytes);
|
||||
}
|
||||
|
||||
riff.info = Object.keys(info).length ? info : null;
|
||||
report.tags.raw.riff = riff;
|
||||
if (riff.info) mapCommon(report, riff.info, RIFF_TO_COMMON);
|
||||
}
|
||||
|
||||
/** Processes a single RIFF chunk, updating riff state and the report. */
|
||||
function processRiffChunk(b, c, riff, info, report, maxTextBytes) {
|
||||
if (c.id === "ds64") {
|
||||
riff.ds64 = { present: true, size: c.size };
|
||||
if (!report.detections.metadata_systems.includes("bw64_ds64")) report.detections.metadata_systems.push("bw64_ds64");
|
||||
if (report.artifact.container.type === "wav") report.artifact.container.type = "bw64";
|
||||
}
|
||||
|
||||
if (c.id === "LIST" && ascii4(b, c.dataOff) === "INFO") {
|
||||
for (const s of enumerateChunks(b, c.dataOff + 4, c.dataOff + c.size, 10000))
|
||||
info[s.id] = decodeLatin1Trim(b.slice(s.dataOff, s.dataOff + s.size));
|
||||
}
|
||||
|
||||
if (c.id === "bext") {
|
||||
if (!report.detections.metadata_systems.includes("bwf_bext")) report.detections.metadata_systems.push("bwf_bext");
|
||||
riff.bext = parseBext(b, c.dataOff, c.size);
|
||||
}
|
||||
|
||||
if (c.id === "iXML" || c.id === "axml") {
|
||||
const key = c.id === "iXML" ? "ixml" : "axml";
|
||||
if (!report.detections.metadata_systems.includes(key)) report.detections.metadata_systems.push(key);
|
||||
const payload = b.slice(c.dataOff, c.dataOff + c.size);
|
||||
riff[key] = { xml: safeUtf8(payload.slice(0, Math.min(payload.length, maxTextBytes))), truncated: payload.length > maxTextBytes };
|
||||
report.embedded.push({
|
||||
id: `${key}_0`, source: `riff:${c.id}`, content_type: "application/xml",
|
||||
byte_length: payload.length, description: `${c.id} chunk`, filename: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses FLAC metablocks: STREAMINFO, Vorbis Comment, PICTURE. */
|
||||
export function parseFlac(b, report, maxTextBytes) {
|
||||
report.detections.metadata_systems.push("flac_metablocks");
|
||||
|
||||
const blocks = parseFlacMetaBlocks(b);
|
||||
report.tags.raw.flac = { blocks: [] };
|
||||
|
||||
for (const blk of blocks) {
|
||||
report.tags.raw.flac.blocks.push({ type: blk.typeName, length: blk.length });
|
||||
|
||||
if (blk.typeName === "VORBIS_COMMENT") {
|
||||
if (!report.detections.metadata_systems.includes("vorbis_comments")) report.detections.metadata_systems.push("vorbis_comments");
|
||||
const vc = parseVorbisComment(blk.data);
|
||||
report.tags.raw.vorbis_comments = vc;
|
||||
mapVorbisCommon(report, vc);
|
||||
} else if (blk.typeName === "PICTURE") {
|
||||
const pic = parseFlacPicture(blk.data, maxTextBytes);
|
||||
report.embedded.push({
|
||||
id: `cover_art_${report.embedded.filter((x) => x.id.startsWith("cover_art_")).length}`,
|
||||
source: "flac:PICTURE", content_type: pic.mime || null,
|
||||
byte_length: pic.dataLength, description: pic.description || null, filename: null,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses OGG/Opus Vorbis comments. */
|
||||
export function parseOgg(b, report) {
|
||||
if (!report.detections.metadata_systems.includes("ogg_opus_tags")) report.detections.metadata_systems.push("ogg_opus_tags");
|
||||
|
||||
const scanEnd = Math.min(b.length, 1024 * 1024);
|
||||
let tags = null;
|
||||
const opusTagsIdx = indexOfAscii(b, "OpusTags", 0, scanEnd);
|
||||
if (opusTagsIdx >= 0) {
|
||||
report.artifact.container.type = "opus";
|
||||
tags = parseVorbisComment(b.slice(opusTagsIdx + 8, scanEnd));
|
||||
} else {
|
||||
const vorbisIdx = indexOfAscii(b, "\x03vorbis", 0, scanEnd);
|
||||
if (vorbisIdx >= 0) tags = parseVorbisComment(b.slice(vorbisIdx + 7, scanEnd));
|
||||
}
|
||||
|
||||
report.tags.raw.ogg = { has_opustags: opusTagsIdx >= 0, has_vorbis_comment: !!tags };
|
||||
|
||||
if (tags) {
|
||||
if (!report.detections.metadata_systems.includes("vorbis_comments")) report.detections.metadata_systems.push("vorbis_comments");
|
||||
report.tags.raw.vorbis_comments = tags;
|
||||
mapVorbisCommon(report, tags);
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort top-level atom scan for MP4/M4A. */
|
||||
export function parseMp4BestEffort(b, report) {
|
||||
report.detections.metadata_systems.push("mp4_atoms");
|
||||
const atoms = [];
|
||||
|
||||
let off = 0;
|
||||
while (off + 8 <= b.length && atoms.length < 2000) {
|
||||
const size = u32be(b, off);
|
||||
const type = ascii4(b, off + 4);
|
||||
if (size < 8) break;
|
||||
atoms.push({ type, size, offset: off });
|
||||
off += size;
|
||||
}
|
||||
|
||||
report.tags.raw.mp4 = {
|
||||
top_level_atoms: atoms.slice(0, 200),
|
||||
hints: {
|
||||
hasMoov: atoms.some((a) => a.type === "moov"),
|
||||
hasUdta: atoms.some((a) => a.type === "udta"),
|
||||
hasMeta: atoms.some((a) => a.type === "meta"),
|
||||
hasIlst: atoms.some((a) => a.type === "ilst"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Best-effort AIFF/AIFC chunk scanning for NAME, AUTH, ANNO. */
|
||||
export function parseAiffBestEffort(b, report, maxTextBytes) {
|
||||
report.detections.metadata_systems.push("aiff_chunks");
|
||||
let off = 12;
|
||||
const chunks = [];
|
||||
while (off + 8 <= b.length && chunks.length < 2000) {
|
||||
const id = ascii4(b, off);
|
||||
const size = u32be(b, off + 4);
|
||||
const dataOff = off + 8;
|
||||
chunks.push({ id, size, offset: off });
|
||||
|
||||
if (["NAME", "AUTH", "ANNO", "(c) "].includes(id)) {
|
||||
const txt = safeUtf8(b.slice(dataOff, dataOff + Math.min(size, maxTextBytes)));
|
||||
if (!report.tags.raw.aiff) report.tags.raw.aiff = { chunks: [] };
|
||||
report.tags.raw.aiff.chunks.push({ id, value: txt, truncated: size > maxTextBytes });
|
||||
}
|
||||
|
||||
off = dataOff + size + (size % 2);
|
||||
}
|
||||
|
||||
if (!report.tags.raw.aiff) report.tags.raw.aiff = {};
|
||||
report.tags.raw.aiff.chunk_index = chunks.slice(0, 500);
|
||||
|
||||
const nameChunk = report.tags.raw.aiff?.chunks?.find((ch) => ch.id === "NAME")?.value;
|
||||
if (nameChunk) report.tags.common.title = report.tags.common.title || nameChunk;
|
||||
}
|
||||
|
||||
const AAC_SAMPLE_RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
||||
const AAC_PROFILES = ["Main", "LC", "SSR", "LTP"];
|
||||
const AAC_CHANNELS = ["defined in AOT", "mono", "stereo", "3.0", "4.0", "5.0", "5.1", "7.1"];
|
||||
|
||||
/** Parses AAC ADTS frame header for audio parameters. */
|
||||
export function parseAacAdts(b, report) {
|
||||
report.detections.metadata_systems.push("adts_header");
|
||||
if (b.length < 7) return;
|
||||
|
||||
const id = (b[1] >> 3) & 0x01;
|
||||
const profile = (b[2] >> 6) & 0x03;
|
||||
const freqIdx = (b[2] >> 2) & 0x0f;
|
||||
const chanCfg = ((b[2] & 0x01) << 2) | ((b[3] >> 6) & 0x03);
|
||||
|
||||
report.tags.raw.aac = {
|
||||
mpeg_version: id === 1 ? "MPEG-2" : "MPEG-4",
|
||||
profile: AAC_PROFILES[profile] || `Profile ${profile}`,
|
||||
sample_rate: AAC_SAMPLE_RATES[freqIdx] || null,
|
||||
sample_rate_index: freqIdx,
|
||||
channel_configuration: chanCfg,
|
||||
channel_description: AAC_CHANNELS[chanCfg] || null,
|
||||
};
|
||||
}
|
||||
|
||||
const AC3_SAMPLE_RATES = [48000, 44100, 32000];
|
||||
const AC3_BITRATES = [32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320, 384, 448, 512, 576, 640];
|
||||
const AC3_ACMODES = [
|
||||
"2.0 (Ch1+Ch2)", "1.0 (C)", "2.0 (L R)", "3.0 (L C R)",
|
||||
"2.1 (L R S)", "3.1 (L C R S)", "2.2 (L R SL SR)", "3.2 (L C R SL SR)",
|
||||
];
|
||||
|
||||
/** Parses AC3 (Dolby Digital) bitstream info. */
|
||||
export function parseAc3(b, report) {
|
||||
report.detections.metadata_systems.push("ac3_bsi");
|
||||
if (b.length < 8) return;
|
||||
|
||||
const fscod = (b[4] >> 6) & 0x03;
|
||||
const frmsizecod = b[4] & 0x3f;
|
||||
const bsid = (b[5] >> 3) & 0x1f;
|
||||
const bsmod = b[5] & 0x07;
|
||||
const acmod = (b[6] >> 5) & 0x07;
|
||||
|
||||
report.tags.raw.ac3 = {
|
||||
sample_rate: AC3_SAMPLE_RATES[fscod] || null,
|
||||
fscod,
|
||||
bitrate_kbps: AC3_BITRATES[frmsizecod >> 1] || null,
|
||||
frmsizecod, bsid, bsmod, acmod,
|
||||
channel_layout: AC3_ACMODES[acmod] || null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Parses WMA files (ASF container) for content description metadata. */
|
||||
export function parseWmaAsf(b, report) {
|
||||
report.detections.metadata_systems.push("asf_header");
|
||||
if (b.length < 30) return;
|
||||
|
||||
const headerSize = Number(u64le(b, 16));
|
||||
const numObjects = u32le(b, 24);
|
||||
const headerEnd = Math.min(b.length, headerSize);
|
||||
|
||||
const objects = [];
|
||||
let off = 30;
|
||||
|
||||
for (let i = 0; i < numObjects && off + 24 <= headerEnd; i++) {
|
||||
const guid4 = [b[off], b[off + 1], b[off + 2], b[off + 3]];
|
||||
const objSize = Number(u64le(b, off + 16));
|
||||
if (objSize < 24 || off + objSize > headerEnd) break;
|
||||
|
||||
const dataOff = off + 24;
|
||||
const dataLen = objSize - 24;
|
||||
|
||||
if (guid4[0] === 0x33 && guid4[1] === 0x26 && guid4[2] === 0xb2 && guid4[3] === 0x75 && dataLen >= 10) {
|
||||
const cd = parseAsfContentDescription(b, dataOff);
|
||||
if (!report.detections.metadata_systems.includes("asf_content_desc"))
|
||||
report.detections.metadata_systems.push("asf_content_desc");
|
||||
if (!report.tags.raw.asf) report.tags.raw.asf = {};
|
||||
report.tags.raw.asf.content_description = cd;
|
||||
mapCommon(report, cd, ASF_CD_TO_COMMON);
|
||||
}
|
||||
|
||||
if (guid4[0] === 0x40 && guid4[1] === 0xa4 && guid4[2] === 0xd0 && guid4[3] === 0xd2 && dataLen >= 2) {
|
||||
const ext = parseAsfExtContentDescription(b, dataOff, dataOff + dataLen);
|
||||
if (!report.detections.metadata_systems.includes("asf_ext_content_desc"))
|
||||
report.detections.metadata_systems.push("asf_ext_content_desc");
|
||||
if (!report.tags.raw.asf) report.tags.raw.asf = {};
|
||||
report.tags.raw.asf.extended_content = ext;
|
||||
|
||||
const c = report.tags.common;
|
||||
for (const d of ext) {
|
||||
const field = WMA_TO_COMMON[(d.name || "").toUpperCase()];
|
||||
if (field && d.value) c[field] = c[field] || d.value;
|
||||
}
|
||||
}
|
||||
|
||||
objects.push({ guid_prefix: guid4.map(x => x.toString(16).padStart(2, "0")).join(""), size: objSize });
|
||||
off += objSize;
|
||||
}
|
||||
|
||||
if (!report.tags.raw.asf) report.tags.raw.asf = {};
|
||||
report.tags.raw.asf.header_objects = objects;
|
||||
}
|
||||
|
||||
const ID3_FRAME_DESCRIPTIONS = {
|
||||
TIT2: "Title/songname/content description", TPE1: "Lead performer(s)/Soloist(s)",
|
||||
TRCK: "Track number/Position in set", TALB: "Album/Movie/Show title",
|
||||
TDRC: "Recording time", TYER: "Year", TCON: "Content type",
|
||||
TPE2: "Band/orchestra/accompaniment", TLEN: "Length (ms)", TCOM: "Composer",
|
||||
COMM: "Comments", APIC: "Attached picture", GEOB: "General encapsulated object",
|
||||
TXXX: "User defined text information frame", UFID: "Unique file identifier", PRIV: "Private frame",
|
||||
};
|
||||
|
||||
const ID3_TO_COMMON = {
|
||||
TIT2: "title", TPE1: "artist", TALB: "album", TDRC: "date", TYER: "date",
|
||||
TRCK: "track", TCON: "genre", COMM: "comment", TCOM: "composer", TCOP: "copyright", TLAN: "language",
|
||||
};
|
||||
const VORBIS_TO_COMMON = {
|
||||
TITLE: "title", ARTIST: "artist", ALBUM: "album", DATE: "date",
|
||||
TRACKNUMBER: "track", GENRE: "genre", COMMENT: "comment", COMPOSER: "composer", LANGUAGE: "language",
|
||||
};
|
||||
const WMA_TO_COMMON = {
|
||||
"WM/ALBUMTITLE": "album", "WM/GENRE": "genre", "WM/YEAR": "date",
|
||||
"WM/TRACKNUMBER": "track", "WM/COMPOSER": "composer", "WM/LANGUAGE": "language",
|
||||
};
|
||||
const ID3V1_TO_COMMON = { title: "title", artist: "artist", album: "album", year: "date", comment: "comment", genre: "genre", track: "track" };
|
||||
const RIFF_TO_COMMON = { INAM: "title", IART: "artist", ICMT: "comment", IGNR: "genre", ICRD: "date", ICOP: "copyright" };
|
||||
const ASF_CD_TO_COMMON = { title: "title", author: "artist", copyright: "copyright", description: "comment" };
|
||||
|
||||
/** Maps source object fields to the common tags layer via a mapping table. */
|
||||
function mapCommon(report, source, mapping) {
|
||||
const c = report.tags.common;
|
||||
for (const [sk, ck] of Object.entries(mapping))
|
||||
c[ck] = c[ck] || source[sk] || null;
|
||||
}
|
||||
|
||||
/** Maps an ID3v2 frame value to the common tags layer. */
|
||||
function mapCommonId3(report, frameId, text) {
|
||||
const field = ID3_TO_COMMON[frameId];
|
||||
if (field) report.tags.common[field] = report.tags.common[field] || text || null;
|
||||
}
|
||||
|
||||
/** Decodes an ID3v2 COMM (Comments) frame. */
|
||||
function decodeCommFrame(data) {
|
||||
if (!data || data.length < 5) return null;
|
||||
const enc = data[0];
|
||||
const language = String.fromCharCode(data[1], data[2], data[3]);
|
||||
const { valueBytes: descBytes, next } = readNullTerminated(data, 4, enc);
|
||||
const short_description = decodeText(descBytes, enc).replace(/\u0000/g, "").trim() || null;
|
||||
const text = decodeText(data.slice(next), enc).replace(/\u0000/g, "").trim() || null;
|
||||
return { language, short_description, text };
|
||||
}
|
||||
|
||||
/** Normalizes TLEN to integer milliseconds. */
|
||||
function normalizeTlen(s) {
|
||||
if (!s) return null;
|
||||
if (/^\s*\d+\s*$/.test(s)) return parseInt(s.trim(), 10);
|
||||
const f = Number(s);
|
||||
if (Number.isFinite(f) && f > 0 && f < 100000) return Math.round(f * 1000);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parses the ID3v2 tag header and frames. */
|
||||
function parseId3v2(mp3) {
|
||||
if (mp3.length < 10 || mp3[0] !== 0x49 || mp3[1] !== 0x44 || mp3[2] !== 0x33) return null;
|
||||
|
||||
const major = mp3[3], minor = mp3[4], flags = mp3[5];
|
||||
const tagSize = synchsafeToInt(mp3[6], mp3[7], mp3[8], mp3[9]);
|
||||
let offset = 10;
|
||||
const end = 10 + tagSize;
|
||||
|
||||
const frames = [];
|
||||
while (offset + 10 <= end) {
|
||||
const id = String.fromCharCode(mp3[offset], mp3[offset + 1], mp3[offset + 2], mp3[offset + 3]);
|
||||
if (!/^[A-Z0-9]{4}$/.test(id)) break;
|
||||
const size = major === 4 ?
|
||||
synchsafeToInt(mp3[offset + 4], mp3[offset + 5], mp3[offset + 6], mp3[offset + 7]) :
|
||||
u32be(mp3, offset + 4);
|
||||
offset += 10;
|
||||
if (size <= 0 || offset + size > mp3.length) break;
|
||||
frames.push({ id, size, data: mp3.slice(offset, offset + size) });
|
||||
offset += size;
|
||||
}
|
||||
|
||||
return { header: { version: `${major}.${minor}`, flags, tag_size: tagSize }, frames };
|
||||
}
|
||||
|
||||
/** Parses the 128-byte ID3v1 tag at the end of the file. */
|
||||
function parseId3v1(b) {
|
||||
if (b.length < 128) return null;
|
||||
const off = b.length - 128;
|
||||
if (b[off] !== 0x54 || b[off + 1] !== 0x41 || b[off + 2] !== 0x47) return null;
|
||||
|
||||
let track = null;
|
||||
if (b[off + 125] === 0x00 && b[off + 126] !== 0x00) track = String(b[off + 126]);
|
||||
|
||||
return {
|
||||
title: decodeLatin1Trim(b.slice(off + 3, off + 33)),
|
||||
artist: decodeLatin1Trim(b.slice(off + 33, off + 63)),
|
||||
album: decodeLatin1Trim(b.slice(off + 63, off + 93)),
|
||||
year: decodeLatin1Trim(b.slice(off + 93, off + 97)),
|
||||
comment: decodeLatin1Trim(b.slice(off + 97, off + 127)),
|
||||
track, genre: String(b[off + 127]),
|
||||
};
|
||||
}
|
||||
|
||||
/** Decodes an ID3v2 TXXX (user-defined text) frame. */
|
||||
function decodeTxxx(data) {
|
||||
if (!data || data.length < 2) return null;
|
||||
const enc = data[0];
|
||||
const { valueBytes: descBytes, next } = readNullTerminated(data, 1, enc);
|
||||
const desc = decodeText(descBytes, enc).replace(/\u0000/g, "").trim();
|
||||
const val = decodeText(data.slice(next), enc).replace(/\u0000/g, "").trim();
|
||||
return { description: desc || null, value: val || null };
|
||||
}
|
||||
|
||||
/** Best-effort APEv2 tag parser scanning the last 32 KB. */
|
||||
function parseApeV2BestEffort(b) {
|
||||
const scanStart = Math.max(0, b.length - 32768);
|
||||
const idx = indexOfAscii(b, "APETAGEX", scanStart, b.length);
|
||||
if (idx < 0) return null;
|
||||
if (idx + 32 > b.length) return { present: true, warning: "APETAGEX found but footer truncated." };
|
||||
|
||||
const ver = u32le(b, idx + 8), size = u32le(b, idx + 12);
|
||||
const count = u32le(b, idx + 16), flags = u32le(b, idx + 20);
|
||||
|
||||
const tagStart = idx + 32 - size;
|
||||
if (tagStart < 0 || tagStart >= b.length)
|
||||
return { present: true, version: ver, size, count, flags, warning: "APEv2 bounds invalid (non-standard placement)." };
|
||||
|
||||
const items = [];
|
||||
let off = tagStart + 32;
|
||||
const end = Math.min(b.length, idx);
|
||||
while (off + 8 < end && items.length < 5000) {
|
||||
const valueSize = u32le(b, off), itemFlags = u32le(b, off + 4);
|
||||
off += 8;
|
||||
let keyEnd = off;
|
||||
while (keyEnd < end && b[keyEnd] !== 0x00) keyEnd++;
|
||||
const key = decodeLatin1Trim(b.slice(off, keyEnd));
|
||||
off = keyEnd + 1;
|
||||
if (!key || off + valueSize > end) break;
|
||||
const value = safeUtf8(b.slice(off, off + valueSize)).replace(/\u0000/g, "").trim();
|
||||
off += valueSize;
|
||||
items.push({ key, value, flags: itemFlags });
|
||||
}
|
||||
|
||||
return { present: true, version: ver, size, count, flags, items };
|
||||
}
|
||||
|
||||
/** Enumerates RIFF-style chunks (id + LE32 size) within a byte range, padding to even. */
|
||||
function enumerateChunks(b, start, end, maxCount) {
|
||||
const chunks = [];
|
||||
let off = start;
|
||||
while (off + 8 <= end && chunks.length < maxCount) {
|
||||
const id = ascii4(b, off);
|
||||
const size = u32le(b, off + 4);
|
||||
const dataOff = off + 8;
|
||||
if (dataOff + size > end) break;
|
||||
chunks.push({ id, size, dataOff });
|
||||
off = dataOff + size + (size % 2);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/** Parses a BWF bext chunk. */
|
||||
function parseBext(b, off, size) {
|
||||
const slice = b.slice(off, off + size);
|
||||
const timeRefLow = u32le(slice, 338), timeRefHigh = u32le(slice, 342);
|
||||
return {
|
||||
description: decodeLatin1Trim(slice.slice(0, 256)) || null,
|
||||
originator: decodeLatin1Trim(slice.slice(256, 288)) || null,
|
||||
originator_reference: decodeLatin1Trim(slice.slice(288, 320)) || null,
|
||||
origination_date: decodeLatin1Trim(slice.slice(320, 330)) || null,
|
||||
origination_time: decodeLatin1Trim(slice.slice(330, 338)) || null,
|
||||
time_reference_samples: ((BigInt(timeRefHigh) << 32n) | BigInt(timeRefLow)).toString(),
|
||||
};
|
||||
}
|
||||
|
||||
const FLAC_TYPE_NAMES = { 0: "STREAMINFO", 1: "PADDING", 2: "APPLICATION", 3: "SEEKTABLE", 4: "VORBIS_COMMENT", 5: "CUESHEET", 6: "PICTURE" };
|
||||
|
||||
/** Parses FLAC metadata blocks following the "fLaC" marker. */
|
||||
function parseFlacMetaBlocks(b) {
|
||||
const blocks = [];
|
||||
let off = 4;
|
||||
while (off + 4 <= b.length && blocks.length < 10000) {
|
||||
const header = b[off];
|
||||
const isLast = (header & 0x80) !== 0;
|
||||
const type = header & 0x7f;
|
||||
const len = (b[off + 1] << 16) | (b[off + 2] << 8) | b[off + 3];
|
||||
off += 4;
|
||||
if (off + len > b.length) break;
|
||||
blocks.push({ type, typeName: FLAC_TYPE_NAMES[type] || `TYPE_${type}`, length: len, data: b.slice(off, off + len) });
|
||||
off += len;
|
||||
if (isLast) break;
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/** Parses a Vorbis Comment block (used by FLAC and OGG). */
|
||||
function parseVorbisComment(buf) {
|
||||
let off = 0;
|
||||
const vendorLen = u32le(buf, off); off += 4;
|
||||
if (off + vendorLen > buf.length) return { vendor: null, comments: [], warning: "vendor_len out of bounds" };
|
||||
const vendor = safeUtf8(buf.slice(off, off + vendorLen)); off += vendorLen;
|
||||
const count = u32le(buf, off); off += 4;
|
||||
|
||||
const comments = [];
|
||||
for (let i = 0; i < count && off + 4 <= buf.length && comments.length < 20000; i++) {
|
||||
const l = u32le(buf, off); off += 4;
|
||||
if (off + l > buf.length) break;
|
||||
const s = safeUtf8(buf.slice(off, off + l)); off += l;
|
||||
const eq = s.indexOf("=");
|
||||
if (eq > 0) comments.push({ key: s.slice(0, eq).toUpperCase(), value: s.slice(eq + 1) });
|
||||
}
|
||||
return { vendor, comments };
|
||||
}
|
||||
|
||||
/** Maps Vorbis Comment fields to the common tags layer. */
|
||||
function mapVorbisCommon(report, vc) {
|
||||
const c = report.tags.common;
|
||||
for (const [vk, ck] of Object.entries(VORBIS_TO_COMMON))
|
||||
c[ck] = c[ck] || vc.comments?.find((x) => x.key === vk)?.value || null;
|
||||
}
|
||||
|
||||
/** Parses a FLAC PICTURE metadata block (extracts mime, description, data length). */
|
||||
function parseFlacPicture(data, maxTextBytes) {
|
||||
let off = 4;
|
||||
const mimeLen = u32be(data, off); off += 4;
|
||||
const mime = safeUtf8(data.slice(off, off + Math.min(mimeLen, maxTextBytes))); off += mimeLen;
|
||||
const descLen = u32be(data, off); off += 4;
|
||||
const description = safeUtf8(data.slice(off, off + Math.min(descLen, maxTextBytes))); off += descLen + 16;
|
||||
return { mime, description, dataLength: u32be(data, off) };
|
||||
}
|
||||
|
||||
/** Parses the ASF Content Description Object fields. */
|
||||
function parseAsfContentDescription(b, off) {
|
||||
const titleLen = u16le(b, off), authorLen = u16le(b, off + 2);
|
||||
const copyrightLen = u16le(b, off + 4), descLen = u16le(b, off + 6), ratingLen = u16le(b, off + 8);
|
||||
let pos = off + 10;
|
||||
const title = decodeUtf16LE(b, pos, titleLen); pos += titleLen;
|
||||
const author = decodeUtf16LE(b, pos, authorLen); pos += authorLen;
|
||||
const copyright = decodeUtf16LE(b, pos, copyrightLen); pos += copyrightLen;
|
||||
const description = decodeUtf16LE(b, pos, descLen); pos += descLen;
|
||||
const rating = decodeUtf16LE(b, pos, ratingLen);
|
||||
return { title, author, copyright, description, rating };
|
||||
}
|
||||
|
||||
/** Parses the ASF Extended Content Description Object descriptors. */
|
||||
function parseAsfExtContentDescription(b, off, end) {
|
||||
const count = u16le(b, off);
|
||||
let pos = off + 2;
|
||||
const descriptors = [];
|
||||
for (let i = 0; i < count && pos + 6 <= end && descriptors.length < 5000; i++) {
|
||||
const nameLen = u16le(b, pos); pos += 2;
|
||||
if (pos + nameLen > end) break;
|
||||
const name = decodeUtf16LE(b, pos, nameLen); pos += nameLen;
|
||||
const valueType = u16le(b, pos); pos += 2;
|
||||
const valueLen = u16le(b, pos); pos += 2;
|
||||
if (pos + valueLen > end) break;
|
||||
let value;
|
||||
if (valueType === 0) value = decodeUtf16LE(b, pos, valueLen);
|
||||
else if (valueType === 3) value = u32le(b, pos);
|
||||
else if (valueType === 5) value = u16le(b, pos);
|
||||
else if (valueType === 2) value = u32le(b, pos) !== 0;
|
||||
else value = `(${valueLen} bytes, type ${valueType})`;
|
||||
pos += valueLen;
|
||||
descriptors.push({ name, value_type: valueType, value });
|
||||
}
|
||||
return descriptors;
|
||||
}
|
||||
175
src/core/operations/ExtractAudioMetadata.mjs
Normal file
175
src/core/operations/ExtractAudioMetadata.mjs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
/**
|
||||
* @author d0s1nt [d0s1nt@cyberchefaudio]
|
||||
* @copyright Crown Copyright 2025
|
||||
* @license Apache-2.0
|
||||
*/
|
||||
|
||||
import Operation from "../Operation.mjs";
|
||||
import OperationError from "../errors/OperationError.mjs";
|
||||
import Utils from "../Utils.mjs";
|
||||
import { makeEmptyReport, sniffContainer } from "../lib/AudioMetaSchema.mjs";
|
||||
import {
|
||||
parseMp3, parseRiffWave, parseFlac, parseOgg,
|
||||
parseMp4BestEffort, parseAiffBestEffort,
|
||||
parseAacAdts, parseAc3, parseWmaAsf,
|
||||
} from "../lib/AudioParsers.mjs";
|
||||
|
||||
/**
|
||||
* Extract Audio Metadata operation.
|
||||
*/
|
||||
class ExtractAudioMetadata extends Operation {
|
||||
/** Creates the Extract Audio Metadata operation. */
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.name = "Extract Audio Metadata";
|
||||
this.module = "Default";
|
||||
this.description =
|
||||
"Extract common audio metadata across MP3 (ID3v2/ID3v1/GEOB), WAV/BWF/BW64 (INFO/bext/iXML/axml), FLAC (Vorbis Comment/Picture), OGG (Vorbis/OpusTags), AAC (ADTS), AC3 (Dolby Digital), WMA (ASF), plus best-effort MP4/M4A and AIFF scanning. Outputs normalized JSON.";
|
||||
this.infoURL = "https://wikipedia.org/wiki/Audio_file_format";
|
||||
this.inputType = "ArrayBuffer";
|
||||
this.outputType = "JSON";
|
||||
this.presentType = "html";
|
||||
|
||||
this.args = [
|
||||
{ name: "Filename (optional)", type: "string", value: "" },
|
||||
{ name: "Max embedded text bytes (iXML/axml/etc)", type: "number", value: 1024 * 512 },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ArrayBuffer} input
|
||||
* @param {Object[]} args
|
||||
* @returns {Object}
|
||||
*/
|
||||
run(input, args) {
|
||||
const filename = (args?.[0] || "").trim() || null;
|
||||
const maxTextBytes = Number.isFinite(args?.[1]) ? Math.max(1024, args[1]) : 1024 * 512;
|
||||
|
||||
if (!(input instanceof ArrayBuffer) || input.byteLength === 0)
|
||||
throw new OperationError("No input data. Load an audio file (drag/drop or use the open file button).");
|
||||
|
||||
const bytes = new Uint8Array(input);
|
||||
const container = sniffContainer(bytes);
|
||||
const report = makeEmptyReport(filename, bytes.length, container);
|
||||
|
||||
try {
|
||||
const parsers = {
|
||||
mp3: () => parseMp3(bytes, report),
|
||||
wav: () => parseRiffWave(bytes, report, maxTextBytes),
|
||||
bw64: () => parseRiffWave(bytes, report, maxTextBytes),
|
||||
flac: () => parseFlac(bytes, report, maxTextBytes),
|
||||
ogg: () => parseOgg(bytes, report),
|
||||
opus: () => parseOgg(bytes, report),
|
||||
mp4: () => parseMp4BestEffort(bytes, report),
|
||||
m4a: () => parseMp4BestEffort(bytes, report),
|
||||
aiff: () => parseAiffBestEffort(bytes, report, maxTextBytes),
|
||||
aac: () => parseAacAdts(bytes, report),
|
||||
ac3: () => parseAc3(bytes, report),
|
||||
wma: () => parseWmaAsf(bytes, report),
|
||||
};
|
||||
if (parsers[container.type]) {
|
||||
parsers[container.type]();
|
||||
} else {
|
||||
report.errors.push({ stage: "sniff", message: "Unknown/unsupported container (best-effort scan not implemented)." });
|
||||
}
|
||||
} catch (e) {
|
||||
report.errors.push({ stage: "parse", message: String(e?.message || e) });
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/** Renders the extracted metadata as an HTML table. */
|
||||
present(data) {
|
||||
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 objRows = (obj, filter = (v) => v !== null) => {
|
||||
for (const [k, v] of Object.entries(obj)) {
|
||||
if (filter(v)) html += row(k, v);
|
||||
}
|
||||
};
|
||||
const objSection = (obj, title, filter) => {
|
||||
if (!obj) return;
|
||||
html += section(title);
|
||||
objRows(obj, filter);
|
||||
};
|
||||
const listSection = (arr, title, fmt) => {
|
||||
if (!arr?.length) return;
|
||||
html += section(title);
|
||||
for (const item of arr) html += fmt(item);
|
||||
};
|
||||
|
||||
let html = `<table class="table table-hover table-sm table-bordered table-nonfluid">\n`;
|
||||
|
||||
html += section("Artifact");
|
||||
html += row("Filename", data.artifact?.filename || "(none)");
|
||||
html += row("Size", `${(data.artifact?.byte_length ?? 0).toLocaleString()} bytes`);
|
||||
html += row("Container", data.artifact?.container?.type);
|
||||
html += row("MIME", data.artifact?.container?.mime);
|
||||
if (data.artifact?.container?.brand) html += row("Brand", data.artifact.container.brand);
|
||||
|
||||
html += section("Detections");
|
||||
html += row("Metadata systems", (data.detections?.metadata_systems || []).join(", ") || "None");
|
||||
html += row("Provenance systems", (data.detections?.provenance_systems || []).join(", ") || "None");
|
||||
|
||||
const common = data.tags?.common || {};
|
||||
html += section("Common Tags");
|
||||
if (Object.values(common).some((v) => v !== null)) {
|
||||
for (const [key, val] of Object.entries(common)) {
|
||||
if (val !== null) html += row(key.charAt(0).toUpperCase() + key.slice(1), val);
|
||||
}
|
||||
} else {
|
||||
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)`);
|
||||
return row(f.id + (f.description ? ` \u2014 ${f.description}` : ""), val);
|
||||
});
|
||||
objSection(data.tags?.raw?.id3v1, "ID3v1", (v) => !!v);
|
||||
listSection(data.tags?.raw?.apev2?.items, "APEv2 Tags", (i) => row(i.key, i.value));
|
||||
|
||||
if (data.tags?.raw?.vorbis_comments?.comments?.length) {
|
||||
html += section("Vorbis Comments");
|
||||
html += row("Vendor", data.tags.raw.vorbis_comments.vendor);
|
||||
for (const c of data.tags.raw.vorbis_comments.comments) html += row(c.key, c.value);
|
||||
}
|
||||
|
||||
objSection(data.tags?.raw?.riff?.info, "RIFF INFO", () => true);
|
||||
objSection(data.tags?.raw?.riff?.bext, "BWF bext");
|
||||
listSection(data.tags?.raw?.riff?.chunks, "RIFF Chunks", (c) => row(c.id, `${c.size} bytes @ offset ${c.offset}`));
|
||||
listSection(data.tags?.raw?.flac?.blocks, "FLAC Metadata Blocks", (b) => row(b.type, `${b.length} bytes`));
|
||||
|
||||
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}`);
|
||||
if (atoms.length > 50) html += row("...", `${atoms.length - 50} more atoms`);
|
||||
}
|
||||
|
||||
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`));
|
||||
|
||||
if (data.provenance?.c2pa?.present) {
|
||||
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`);
|
||||
}
|
||||
|
||||
listSection(data.errors, "Errors", (e) => row(e.stage, e.message));
|
||||
|
||||
html += "</table>";
|
||||
return html;
|
||||
}
|
||||
}
|
||||
|
||||
export default ExtractAudioMetadata;
|
||||
|
|
@ -67,6 +67,7 @@ import "./tests/DropNthBytes.mjs";
|
|||
import "./tests/ECDSA.mjs";
|
||||
import "./tests/ELFInfo.mjs";
|
||||
import "./tests/Enigma.mjs";
|
||||
import "./tests/ExtractAudioMetadata.mjs";
|
||||
import "./tests/ExtractEmailAddresses.mjs";
|
||||
import "./tests/ExtractHashes.mjs";
|
||||
import "./tests/ExtractIPAddresses.mjs";
|
||||
|
|
|
|||
287
tests/operations/tests/ExtractAudioMetadata.mjs
Normal file
287
tests/operations/tests/ExtractAudioMetadata.mjs
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
/**
|
||||
* Extract Audio Metadata operation tests.
|
||||
*
|
||||
* @author d0s1nt
|
||||
* @copyright Crown Copyright 2025
|
||||
* @license Apache-2.0
|
||||
*/
|
||||
import TestRegister from "../../lib/TestRegister.mjs";
|
||||
import {
|
||||
MP3_HEX, WAV_HEX, FLAC_HEX, AAC_HEX,
|
||||
AC3_HEX, OGG_HEX, OPUS_HEX, WMA_HEX,
|
||||
M4A_HEX, AIFF_HEX
|
||||
} from "../../samples/Audio.mjs";
|
||||
|
||||
TestRegister.addTests([
|
||||
// ---- MP3 ----
|
||||
{
|
||||
name: "Extract Audio Metadata: MP3 container and MIME",
|
||||
input: MP3_HEX,
|
||||
expectedMatch: /Container<\/td><td>mp3<\/td>.*MIME<\/td><td>audio\/mpeg<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.mp3", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: MP3 common tags (title, artist)",
|
||||
input: MP3_HEX,
|
||||
expectedMatch: /Title<\/td><td>Galway<\/td>.*Artist<\/td><td>Kevin MacLeod<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.mp3", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: MP3 ID3v2 frames (TIT2, TPE1, TSSE)",
|
||||
input: MP3_HEX,
|
||||
expectedMatch: /ID3v2 Frames.*TIT2.*Galway.*TPE1.*Kevin MacLeod.*TSSE.*Lavf56\.40\.101/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.mp3", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: MP3 detections (id3v2)",
|
||||
input: MP3_HEX,
|
||||
expectedMatch: /Metadata systems<\/td><td>id3v2<\/td>/,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.mp3", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- WAV ----
|
||||
{
|
||||
name: "Extract Audio Metadata: WAV container and MIME",
|
||||
input: WAV_HEX,
|
||||
expectedMatch: /Container<\/td><td>wav<\/td>.*MIME<\/td><td>audio\/wav<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.wav", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: WAV RIFF chunks (fmt)",
|
||||
input: WAV_HEX,
|
||||
expectedMatch: /RIFF Chunks.*fmt .*16 bytes @ offset 20/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.wav", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- FLAC ----
|
||||
{
|
||||
name: "Extract Audio Metadata: FLAC container and common tags",
|
||||
input: FLAC_HEX,
|
||||
expectedMatch: /Container<\/td><td>flac<\/td>.*Title<\/td><td>Galway<\/td>.*Artist<\/td><td>Kevin MacLeod<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.flac", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: FLAC metadata blocks (STREAMINFO, VORBIS_COMMENT)",
|
||||
input: FLAC_HEX,
|
||||
expectedMatch: /FLAC Metadata Blocks.*STREAMINFO<\/td><td>34 bytes<\/td>.*VORBIS_COMMENT<\/td><td>86 bytes<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.flac", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: FLAC Vorbis comments (vendor, tags)",
|
||||
input: FLAC_HEX,
|
||||
expectedMatch: /Vorbis Comments.*Vendor<\/td><td>Lavf56\.40\.101<\/td>.*TITLE<\/td><td>Galway<\/td>.*ARTIST<\/td><td>Kevin MacLeod<\/td>.*ENCODER<\/td><td>Lavf56\.40\.101<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.flac", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: FLAC detections",
|
||||
input: FLAC_HEX,
|
||||
expectedMatch: /Metadata systems<\/td><td>flac_metablocks, vorbis_comments<\/td>/,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.flac", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- AAC ----
|
||||
{
|
||||
name: "Extract Audio Metadata: AAC container and MIME",
|
||||
input: AAC_HEX,
|
||||
expectedMatch: /Container<\/td><td>aac<\/td>.*MIME<\/td><td>audio\/aac<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.aac", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: AAC ADTS technical fields",
|
||||
input: AAC_HEX,
|
||||
expectedMatch: /AAC ADTS.*mpeg_version<\/td><td>MPEG-4<\/td>.*profile<\/td><td>LC<\/td>.*sample_rate<\/td><td>44100<\/td>.*channel_description<\/td><td>stereo<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.aac", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- AC3 ----
|
||||
{
|
||||
name: "Extract Audio Metadata: AC3 container and MIME",
|
||||
input: AC3_HEX,
|
||||
expectedMatch: /Container<\/td><td>ac3<\/td>.*MIME<\/td><td>audio\/ac3<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.ac3", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: AC3 technical fields (sample rate, bitrate, channels)",
|
||||
input: AC3_HEX,
|
||||
expectedMatch: /AC3 \(Dolby Digital\).*sample_rate<\/td><td>44100<\/td>.*bitrate_kbps<\/td><td>192<\/td>.*channel_layout<\/td><td>2\.0 \(L R\)<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.ac3", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- OGG Vorbis ----
|
||||
{
|
||||
name: "Extract Audio Metadata: OGG container and common tags",
|
||||
input: OGG_HEX,
|
||||
expectedMatch: /Container<\/td><td>ogg<\/td>.*Title<\/td><td>Galway<\/td>.*Artist<\/td><td>Kevin MacLeod<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.ogg", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: OGG Vorbis comments (vendor, encoder)",
|
||||
input: OGG_HEX,
|
||||
expectedMatch: /Vorbis Comments.*Vendor<\/td><td>Lavf56\.40\.101<\/td>.*ENCODER<\/td><td>Lavc56\.60\.100 libvorbis<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.ogg", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- Opus ----
|
||||
{
|
||||
name: "Extract Audio Metadata: Opus container and common tags",
|
||||
input: OPUS_HEX,
|
||||
expectedMatch: /Container<\/td><td>opus<\/td>.*Title<\/td><td>Galway<\/td>.*Artist<\/td><td>Kevin MacLeod<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.opus", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: Opus Vorbis comments (vendor, encoder)",
|
||||
input: OPUS_HEX,
|
||||
expectedMatch: /Vorbis Comments.*Vendor<\/td><td>Lavf58\.19\.102<\/td>.*ENCODER<\/td><td>Lavc58\.34\.100 libopus<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.opus", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- WMA/ASF ----
|
||||
{
|
||||
name: "Extract Audio Metadata: WMA container and MIME",
|
||||
input: WMA_HEX,
|
||||
expectedMatch: /Container<\/td><td>wma<\/td>.*MIME<\/td><td>audio\/x-ms-wma<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.wma", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: WMA common tags (title, artist)",
|
||||
input: WMA_HEX,
|
||||
expectedMatch: /Title<\/td><td>Galway<\/td>.*Artist<\/td><td>Kevin MacLeod<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.wma", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: WMA ASF Content Description",
|
||||
input: WMA_HEX,
|
||||
expectedMatch: /ASF Content Description.*title<\/td><td>Galway<\/td>.*author<\/td><td>Kevin MacLeod<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.wma", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: WMA ASF Extended Content (encoding settings)",
|
||||
input: WMA_HEX,
|
||||
expectedMatch: /ASF Extended Content.*WM\/EncodingSettings<\/td><td>Lavf56\.40\.101<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.wma", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: WMA detections",
|
||||
input: WMA_HEX,
|
||||
expectedMatch: /Metadata systems<\/td><td>asf_header, asf_content_desc, asf_ext_content_desc<\/td>/,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.wma", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- M4A ----
|
||||
{
|
||||
name: "Extract Audio Metadata: M4A container, MIME and brand",
|
||||
input: M4A_HEX,
|
||||
expectedMatch: /Container<\/td><td>m4a<\/td>.*MIME<\/td><td>audio\/mp4<\/td>.*Brand<\/td><td>M4A <\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.m4a", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: M4A top-level atoms (ftyp, mdat)",
|
||||
input: M4A_HEX,
|
||||
expectedMatch: /MP4 Top-Level Atoms.*ftyp<\/td>.*mdat<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.m4a", 524288] }
|
||||
]
|
||||
},
|
||||
|
||||
// ---- AIFF ----
|
||||
{
|
||||
name: "Extract Audio Metadata: AIFF container, MIME and brand",
|
||||
input: AIFF_HEX,
|
||||
expectedMatch: /Container<\/td><td>aiff<\/td>.*MIME<\/td><td>audio\/aiff<\/td>.*Brand<\/td><td>AIFF<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.aiff", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: AIFF common tag (title from NAME chunk)",
|
||||
input: AIFF_HEX,
|
||||
expectedMatch: /Title<\/td><td>Galway<\/td>/,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.aiff", 524288] }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "Extract Audio Metadata: AIFF chunks (NAME)",
|
||||
input: AIFF_HEX,
|
||||
expectedMatch: /AIFF Chunks.*NAME<\/td><td>Galway<\/td>/s,
|
||||
recipeConfig: [
|
||||
{ op: "From Hex", args: ["None"] },
|
||||
{ op: "Extract Audio Metadata", args: ["test.aiff", 524288] }
|
||||
]
|
||||
},
|
||||
]);
|
||||
73
tests/samples/Audio.mjs
Normal file
73
tests/samples/Audio.mjs
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
/**
|
||||
* Audio file headers in various formats for use in tests.
|
||||
*
|
||||
* Each constant contains the minimal bytes needed for container
|
||||
* detection and metadata extraction (trimmed from real audio files).
|
||||
*
|
||||
* @author d0s1nt [d0s1nt@cyberchefaudio]
|
||||
* @copyright Crown Copyright 2025
|
||||
* @license Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* MP3 with ID3v2.4 header — title: Galway, artist: Kevin MacLeod
|
||||
* 78 bytes: ID3v2 header + TIT2 + TPE1 + TSSE frames
|
||||
*/
|
||||
export const MP3_HEX = "4944330400000000004e544954320000000800000347616c77617900545045310000000f0000034b6576696e204d61634c656f6400545353450000000f0000034c61766635362e34302e3130310000000000000000000000";
|
||||
|
||||
/**
|
||||
* WAV (RIFF/WAVE) header — 2 channels, 16-bit, 44100 Hz
|
||||
* 48 bytes: RIFF header + fmt chunk + data chunk start
|
||||
*/
|
||||
export const WAV_HEX = "52494646e69d2a0057415645666d7420100000000100020044ac000010b102000400100064617461489d2a0000000100";
|
||||
|
||||
/**
|
||||
* FLAC with streaminfo + Vorbis comment block — title: Galway, artist: Kevin MacLeod
|
||||
* 174 bytes: fLaC magic + STREAMINFO (34 bytes) + VORBIS_COMMENT block (86 bytes)
|
||||
*/
|
||||
export const FLAC_HEX = "664c6143000000221200120000052e00319f0ac442f0000aa752c925754a50e5f02e117eeb46467e7053040000560d0000004c61766635362e34302e313031030000000c0000007469746c653d47616c776179140000006172746973743d4b6576696e204d61634c656f6415000000656e636f6465723d4c61766635362e34302e313031";
|
||||
|
||||
/**
|
||||
* AAC ADTS frame header — MPEG-4, LC profile, 44100 Hz, stereo
|
||||
* 32 bytes: ADTS sync + frame header fields
|
||||
*/
|
||||
export const AAC_HEX = "fff150800bbffcde02004c61766335382e33342e31303000423590002000001e";
|
||||
|
||||
/**
|
||||
* AC3 (Dolby Digital) sync frame header — 44100 Hz, 192 kbps, 2.0 stereo
|
||||
* 32 bytes: AC3 sync word + BSI fields
|
||||
*/
|
||||
export const AC3_HEX = "0b773968544043e106f575f0d4da1c1ac159850953e549a125736e8d37359d3f";
|
||||
|
||||
/**
|
||||
* OGG Vorbis — two OGG pages with identification + comment headers
|
||||
* title: Galway, artist: Kevin MacLeod, vendor: Lavf56.40.101
|
||||
* 281 bytes
|
||||
*/
|
||||
export const OGG_HEX = "4f6767530002000000000000000027a7032a000000002acbc833011e01766f72626973000000000244ac00000000000080b5010000000000b8014f6767530000000000000000000027a7032a010000007e1abea41168ffffffffffffffffffffffffffffff0703766f726269730d0000004c61766635362e34302e313031030000001f000000656e636f6465723d4c61766335362e36302e313030206c6962766f726269730c0000007469746c653d47616c776179140000006172746973743d4b6576696e204d61634c656f64";
|
||||
|
||||
/**
|
||||
* Opus — two OGG pages with OpusHead + OpusTags headers
|
||||
* title: Galway, artist: Kevin MacLeod, vendor: Lavf58.19.102
|
||||
* 233 bytes
|
||||
*/
|
||||
export const OPUS_HEX = "4f67675300020000000000000000919a59f200000000f6117eb601134f707573486561640102380180bb00000000004f67675300000000000000000000919a59f201000000b047e56601664f707573546167730d0000004c61766635382e31392e313032030000001d000000656e636f6465723d4c61766335382e33342e313030206c69626f7075730c0000007469746c653d47616c776179140000006172746973743d4b6576696e204d61634c656f64";
|
||||
|
||||
/**
|
||||
* WMA/ASF — ASF header with Content Description + Extended Content
|
||||
* title: Galway, author: Kevin MacLeod, encoder: Lavf56.40.101
|
||||
* 700 bytes: ASF Header Object + all sub-objects
|
||||
*/
|
||||
export const WMA_HEX = "3026b2758e66cf11a6d900aa0062ce6c8a02000000000000060000000102a1dcab8c47a9cf118ee400c00c205365680000000000000000000000000000000000000000000000bc3504000000000000803ed5deb19d0156000000000000007040490b00000000b03a7009000000001c0c00000000000002000000800c0000800c000000f40100b503bf5f2ea9cf118ee300c00c2053652e0000000000000011d2d3abbaa9cf118ee600c00c2053650600000000003326b2758e66cf11a6d900aa0062ce6c4c000000000000000e001c00000000000000470061006c0077006100790000004b006500760069006e0020004d00610063004c0065006f006400000040a4d0d207e3d21197f000a0c95ea850b40000000000000003000c007400690074006c006500000000000e00470061006c0077006100790000000e0041007500740068006f007200000000001c004b006500760069006e0020004d00610063004c0065006f0064000000280057004d002f0045006e0063006f00640069006e006700530065007400740069006e0067007300000000001c004c00610076006600350036002e00340030002e0031003000310000009107dcb7b7a9cf118ee600c00c2053657200000000000000409e69f84d5bcf11a8fd00805f5c442b50cdc3bf8f61cf118bb200aa00b4e22000000000000000001c000000080000000100000000006101020044ac0000803e0000e70210000a000000000001000000000001e702e7020100004052d1861d31d011a3a400a0c90348f664000000000000004152d1861d31d011a3a400a0c90348f60100000002001700570069006e0064006f007700730020004d006500640069006100200041007500640069006f0020005600380000000000020061013626b2758e66cf11a6d900aa0062ce6c32330400000000000000000000000000000000000000000056000000000000000101";
|
||||
|
||||
/**
|
||||
* M4A (MPEG-4 Audio) — ftyp atom with brand "M4A ", plus mdat
|
||||
* 512 bytes: ftyp + free + mdat start (moov not included in slice)
|
||||
*/
|
||||
export const M4A_HEX = "0000001c667479704d344120000002004d34412069736f6d69736f3200000008667265650003e2236d6461742111450014500146fff10a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5a5de98214b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4b4bc211a93a09c3e310803595989841e21e02814c4d2f28f925da49e5fe61d4521f0088400d65662610788780a0563ab2a671af1d0cd2fd9997d18be037ff8852d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d";
|
||||
|
||||
/**
|
||||
* AIFF (FORM/AIFF) header with NAME chunk = "Galway", COMM and SSND chunks
|
||||
* 36 bytes: FORM header + NAME chunk + COMM chunk start
|
||||
*/
|
||||
export const AIFF_HEX = "464f524d002a9d84414946464e414d450000000647616c776179434f4d4d00000012000200";
|
||||
Loading…
Add table
Add a link
Reference in a new issue