Initial Commit
This commit is contained in:
132
packages/media/audio/audio-metadata/README.md
Normal file
132
packages/media/audio/audio-metadata/README.md
Normal file
File diff suppressed because one or more lines are too long
140
packages/media/audio/audio-metadata/package.json
Normal file
140
packages/media/audio/audio-metadata/package.json
Normal file
@@ -0,0 +1,140 @@
|
||||
{
|
||||
"name": "@suaveplan/audio-metadata",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Universal audio metadata extractor: parses ID3v1/v2, MP4/M4A atoms, FLAC Vorbis comments, Ogg, WAV, AIFF, and returns a normalised Track record.",
|
||||
"license": "MIT",
|
||||
"author": "Suaveplan Team",
|
||||
"main": "./dist/index.js",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
"exports": {
|
||||
"./package.json": "./package.json",
|
||||
".": {
|
||||
"import": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./parsers/id3v2": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/id3v2/id3v2.d.ts",
|
||||
"default": "./dist/parsers/id3v2/id3v2.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/id3v2/id3v2.d.ts",
|
||||
"default": "./dist/parsers/id3v2/id3v2.js"
|
||||
}
|
||||
},
|
||||
"./parsers/id3v1": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/id3v1/id3v1.d.ts",
|
||||
"default": "./dist/parsers/id3v1/id3v1.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/id3v1/id3v1.d.ts",
|
||||
"default": "./dist/parsers/id3v1/id3v1.js"
|
||||
}
|
||||
},
|
||||
"./parsers/mp4-atom": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/mp4-atom/mp4-atom.d.ts",
|
||||
"default": "./dist/parsers/mp4-atom/mp4-atom.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/mp4-atom/mp4-atom.d.ts",
|
||||
"default": "./dist/parsers/mp4-atom/mp4-atom.js"
|
||||
}
|
||||
},
|
||||
"./parsers/flac-vorbis": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/flac-vorbis/flac-vorbis.d.ts",
|
||||
"default": "./dist/parsers/flac-vorbis/flac-vorbis.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/flac-vorbis/flac-vorbis.d.ts",
|
||||
"default": "./dist/parsers/flac-vorbis/flac-vorbis.js"
|
||||
}
|
||||
},
|
||||
"./parsers/ogg-vorbis": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/ogg-vorbis/ogg-vorbis.d.ts",
|
||||
"default": "./dist/parsers/ogg-vorbis/ogg-vorbis.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/ogg-vorbis/ogg-vorbis.d.ts",
|
||||
"default": "./dist/parsers/ogg-vorbis/ogg-vorbis.js"
|
||||
}
|
||||
},
|
||||
"./parsers/wav-riff": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/wav-riff/wav-riff.d.ts",
|
||||
"default": "./dist/parsers/wav-riff/wav-riff.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/wav-riff/wav-riff.d.ts",
|
||||
"default": "./dist/parsers/wav-riff/wav-riff.js"
|
||||
}
|
||||
},
|
||||
"./parsers/aiff-id3": {
|
||||
"import": {
|
||||
"types": "./dist/parsers/aiff-id3/aiff-id3.d.ts",
|
||||
"default": "./dist/parsers/aiff-id3/aiff-id3.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/parsers/aiff-id3/aiff-id3.d.ts",
|
||||
"default": "./dist/parsers/aiff-id3/aiff-id3.js"
|
||||
}
|
||||
},
|
||||
"./errors": {
|
||||
"import": {
|
||||
"types": "./dist/errors/metadata-error.d.ts",
|
||||
"default": "./dist/errors/metadata-error.js"
|
||||
},
|
||||
"require": {
|
||||
"types": "./dist/errors/metadata-error.d.ts",
|
||||
"default": "./dist/errors/metadata-error.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"typescript": "catalog:",
|
||||
"vite": "catalog:build",
|
||||
"vite-plugin-dts": "catalog:build",
|
||||
"vitest": "catalog:testing"
|
||||
},
|
||||
"publishConfig": {
|
||||
"registry": "https://npmjs.fq.io/",
|
||||
"access": "public"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
# extractor
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
The `MetadataExtractor` is the central facade for the `@suaveplan/audio-metadata` package. It accepts audio data in multiple forms (raw `Uint8Array`, `ArrayBuffer`, or a File-like object), detects the format via the shared `detectAudioFormat` utility, dispatches to the appropriate parser, applies caller-controlled options, and records OpenTelemetry metrics for observability.
|
||||
|
||||
The singleton pattern ensures that OTel meter handles are created once and reused across all extraction calls, avoiding repeated instrumentation overhead.
|
||||
|
||||
## Features
|
||||
|
||||
- Singleton `getInstance()` pattern — one extractor instance per process/bundle
|
||||
- `extractFromBuffer(Uint8Array, options?)` — primary extraction path
|
||||
- `extractFromArrayBuffer(ArrayBuffer, options?)` — convenience for browser `fetch` / `FileReader`
|
||||
- `extractFromFile(File|Blob, options?)` — reads `arrayBuffer()` from a File-like object
|
||||
- `extractAudioMetadata(Uint8Array, options?)` — returns the full `AudioMetadata` envelope
|
||||
- `extractMetadata(Uint8Array, options?)` — module-level convenience function for one-shot use
|
||||
- OTel counter `metadata.extract.total{format}` and histogram `metadata.extract.duration_ms`
|
||||
- Structured error wrapping: parser exceptions become `MetadataError` with stable codes
|
||||
- Options propagated to parsers: `skipCovers`, `skipLyrics`, `maxCoverSize`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { extractMetadata } from "@suaveplan/audio-metadata";
|
||||
|
||||
const track = await extractMetadata(audioBuffer);
|
||||
console.log(track.title); // "Come Together"
|
||||
console.log(track.artist); // "The Beatles"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MetadataExtractor } from "@suaveplan/audio-metadata";
|
||||
|
||||
const extractor = MetadataExtractor.getInstance();
|
||||
|
||||
// From a browser File input
|
||||
const file = document.querySelector<HTMLInputElement>("#file-input")!.files![0];
|
||||
const track = await extractor.extractFromFile(file, {
|
||||
skipCovers: false,
|
||||
maxCoverSize: 2 * 1024 * 1024, // 2 MiB max cover
|
||||
skipLyrics: true,
|
||||
});
|
||||
|
||||
// Full envelope with format and bitrate
|
||||
const meta = await extractor.extractAudioMetadata(await file.arrayBuffer(), {});
|
||||
console.log(meta.format); // "id3v2"
|
||||
console.log(meta.bitrate); // 320
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `MetadataExtractor` (class)
|
||||
|
||||
#### `MetadataExtractor.getInstance(): MetadataExtractor`
|
||||
|
||||
Returns the shared singleton instance, creating it on first call.
|
||||
|
||||
#### `extractor.extractFromBuffer(buffer, options?): Promise<Track>`
|
||||
|
||||
Primary extraction path. Detects format, parses, applies options, records metrics.
|
||||
|
||||
- **Throws** `MetadataError(UNSUPPORTED_FORMAT)` for unrecognised buffers.
|
||||
- **Throws** `MetadataError(PARSE_ERROR)` on unexpected parser exceptions.
|
||||
|
||||
#### `extractor.extractFromArrayBuffer(buffer, options?): Promise<Track>`
|
||||
|
||||
Wraps `extractFromBuffer` for callers with an `ArrayBuffer`.
|
||||
|
||||
#### `extractor.extractFromFile(file, options?): Promise<Track>`
|
||||
|
||||
Calls `file.arrayBuffer()` then `extractFromArrayBuffer`.
|
||||
|
||||
- **Throws** `MetadataError(FETCH_FAILED)` if `arrayBuffer()` rejects.
|
||||
|
||||
#### `extractor.extractAudioMetadata(buffer, options?): Promise<AudioMetadata>`
|
||||
|
||||
Returns `{ format, track, duration?, bitrate? }`.
|
||||
|
||||
### `extractMetadata(buffer, options?): Promise<Track>`
|
||||
|
||||
Module-level function; delegates to `MetadataExtractor.getInstance().extractFromBuffer(...)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
OTel metrics are loaded with a dynamic `import()` so that environments without `@opentelemetry/api` installed (or where the package is not available at runtime) do not throw. The `try/catch` around the dynamic import and around individual metric calls ensures that OTel failures are always swallowed — telemetry must never break audio parsing. The `performance.now()` call used for duration measurement is available in all modern environments (Node ≥ 16, all browsers, Deno) without polyfilling. OTel meter handles are cached in module-level variables rather than instance variables so they survive singleton reset in tests.
|
||||
@@ -0,0 +1,490 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { METADATA_CODES, MetadataError } from "../../errors/metadata-error.js";
|
||||
import { extractMetadata, MetadataExtractor } from "./extractor.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Buffer builders (reuse from other tests)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function encodeSyncsafe(n: number): number[] {
|
||||
return [(n >> 21) & 0x7f, (n >> 14) & 0x7f, (n >> 7) & 0x7f, n & 0x7f];
|
||||
}
|
||||
|
||||
function uint32BE(n: number): number[] {
|
||||
return [(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
function buildId3v2Buffer(title: string): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const textBytes = Array.from(enc.encode(title));
|
||||
const frameData = [0x00, ...textBytes];
|
||||
const frame = [
|
||||
0x54,
|
||||
0x49,
|
||||
0x54,
|
||||
0x32, // "TIT2"
|
||||
...uint32BE(frameData.length),
|
||||
0x00,
|
||||
0x00,
|
||||
...frameData,
|
||||
];
|
||||
const tagSize = frame.length;
|
||||
const header = [
|
||||
0x49,
|
||||
0x44,
|
||||
0x33,
|
||||
0x03,
|
||||
0x00,
|
||||
0x00,
|
||||
...encodeSyncsafe(tagSize),
|
||||
];
|
||||
return new Uint8Array([...header, ...frame]);
|
||||
}
|
||||
|
||||
function buildId3v1Buffer(title: string): Uint8Array {
|
||||
const buf = new Uint8Array(256);
|
||||
buf[128] = 0x54;
|
||||
buf[129] = 0x41;
|
||||
buf[130] = 0x47;
|
||||
new TextEncoder().encodeInto(title, buf.subarray(131, 161));
|
||||
return buf;
|
||||
}
|
||||
|
||||
function buildFlacBuffer(title: string): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const vendor = "test";
|
||||
const vendorBytes = Array.from(enc.encode(vendor));
|
||||
const commentStr = `TITLE=${title}`;
|
||||
const commentBytes = Array.from(enc.encode(commentStr));
|
||||
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
|
||||
const commentData = [
|
||||
...uint32LE(vendorBytes.length),
|
||||
...vendorBytes,
|
||||
...uint32LE(1), // 1 comment
|
||||
...uint32LE(commentBytes.length),
|
||||
...commentBytes,
|
||||
];
|
||||
|
||||
const len = commentData.length;
|
||||
const blockHeader = [
|
||||
0x80 | 4, // last block, type 4 (VORBIS_COMMENT)
|
||||
(len >> 16) & 0xff,
|
||||
(len >> 8) & 0xff,
|
||||
len & 0xff,
|
||||
];
|
||||
|
||||
return new Uint8Array([
|
||||
0x66,
|
||||
0x4c,
|
||||
0x61,
|
||||
0x43,
|
||||
...blockHeader,
|
||||
...commentData,
|
||||
]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("MetadataExtractor", () => {
|
||||
it("is a singleton", { timeout: 5000 }, () => {
|
||||
const a = MetadataExtractor.getInstance();
|
||||
const b = MetadataExtractor.getInstance();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it("extracts metadata from ID3v2 buffer", { timeout: 30000 }, async () => {
|
||||
const buf = buildId3v2Buffer("Extractor Test");
|
||||
const track =
|
||||
await MetadataExtractor.getInstance().extractFromBuffer(buf);
|
||||
expect(track.title).toBe("Extractor Test");
|
||||
expect(track.format).toBe("id3v2");
|
||||
});
|
||||
|
||||
it("extracts metadata from ID3v1 buffer", { timeout: 30000 }, async () => {
|
||||
const buf = buildId3v1Buffer("V1 Track");
|
||||
const track =
|
||||
await MetadataExtractor.getInstance().extractFromBuffer(buf);
|
||||
expect(track.format).toBe("id3v1");
|
||||
expect(track.title).toBe("V1 Track");
|
||||
});
|
||||
|
||||
it("extracts metadata from FLAC buffer", { timeout: 30000 }, async () => {
|
||||
const buf = buildFlacBuffer("FLAC Title");
|
||||
const track =
|
||||
await MetadataExtractor.getInstance().extractFromBuffer(buf);
|
||||
expect(track.format).toBe("flac");
|
||||
expect(track.title).toBe("FLAC Title");
|
||||
});
|
||||
|
||||
it("throws MetadataError for unknown format", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const buf = new Uint8Array([
|
||||
0xde, 0xad, 0xbe, 0xef, 0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
await expect(
|
||||
MetadataExtractor.getInstance().extractFromBuffer(buf)
|
||||
).rejects.toMatchObject({
|
||||
code: METADATA_CODES.UNSUPPORTED_FORMAT,
|
||||
});
|
||||
});
|
||||
|
||||
it("extractFromArrayBuffer works", { timeout: 30000 }, async () => {
|
||||
const buf = buildId3v2Buffer("ArrayBuffer Test");
|
||||
const track =
|
||||
await MetadataExtractor.getInstance().extractFromArrayBuffer(
|
||||
buf.buffer
|
||||
);
|
||||
expect(track.title).toBe("ArrayBuffer Test");
|
||||
});
|
||||
|
||||
it("extractFromFile works with Blob-like object", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const buf = buildId3v2Buffer("File Test");
|
||||
const fileLike = {
|
||||
arrayBuffer: async () => buf.buffer as ArrayBuffer,
|
||||
};
|
||||
const track =
|
||||
await MetadataExtractor.getInstance().extractFromFile(fileLike);
|
||||
expect(track.title).toBe("File Test");
|
||||
});
|
||||
|
||||
it("extractFromFile throws FETCH_FAILED on rejection", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const fileLike = {
|
||||
arrayBuffer: async () => {
|
||||
throw new Error("Read error");
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
MetadataExtractor.getInstance().extractFromFile(fileLike)
|
||||
).rejects.toMatchObject({
|
||||
code: METADATA_CODES.FETCH_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("extractFromFile throws FETCH_FAILED when non-Error thrown from arrayBuffer", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// Covers `err instanceof Error ? err.message : String(err)` FALSE branch
|
||||
const fileLike = {
|
||||
arrayBuffer: async (): Promise<ArrayBuffer> => {
|
||||
// Throw a non-Error to exercise `String(err)` path
|
||||
return Promise.reject({ message: "not an Error" });
|
||||
},
|
||||
};
|
||||
await expect(
|
||||
MetadataExtractor.getInstance().extractFromFile(fileLike)
|
||||
).rejects.toMatchObject({
|
||||
code: METADATA_CODES.FETCH_FAILED,
|
||||
});
|
||||
});
|
||||
|
||||
it("extractAudioMetadata returns AudioMetadata envelope", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
const buf = buildId3v2Buffer("Envelope Test");
|
||||
const meta =
|
||||
await MetadataExtractor.getInstance().extractAudioMetadata(buf);
|
||||
expect(meta.format).toBe("id3v2");
|
||||
expect(meta.track.title).toBe("Envelope Test");
|
||||
});
|
||||
|
||||
it("respects skipCovers option", { timeout: 30000 }, async () => {
|
||||
const buf = buildId3v2Buffer("No Cover");
|
||||
const track = await MetadataExtractor.getInstance().extractFromBuffer(
|
||||
buf,
|
||||
{
|
||||
skipCovers: true,
|
||||
}
|
||||
);
|
||||
expect(track.cover).toBeUndefined();
|
||||
});
|
||||
|
||||
it("respects skipLyrics option", { timeout: 30000 }, async () => {
|
||||
const buf = buildId3v2Buffer("No Lyrics");
|
||||
const track = await MetadataExtractor.getInstance().extractFromBuffer(
|
||||
buf,
|
||||
{
|
||||
skipLyrics: true,
|
||||
}
|
||||
);
|
||||
expect(track.lyrics).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractMetadata convenience function", () => {
|
||||
it("extracts from ID3v2 buffer", { timeout: 30000 }, async () => {
|
||||
const buf = buildId3v2Buffer("Convenience Test");
|
||||
const track = await extractMetadata(buf);
|
||||
expect(track.title).toBe("Convenience Test");
|
||||
});
|
||||
|
||||
it("propagates options", { timeout: 30000 }, async () => {
|
||||
const buf = buildId3v2Buffer("Options Test");
|
||||
const track = await extractMetadata(buf, { skipCovers: true });
|
||||
expect(track.cover).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MetadataError", () => {
|
||||
it("has correct code and name", { timeout: 5000 }, () => {
|
||||
const err = new MetadataError("test", METADATA_CODES.PARSE_ERROR);
|
||||
expect(err.code).toBe("METADATA.PARSE_ERROR");
|
||||
expect(err.name).toBe("MetadataError");
|
||||
expect(err instanceof Error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional extractor tests for full dispatch coverage
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function buildOggBuffer(title: string): Uint8Array {
|
||||
const enc = new TextEncoder();
|
||||
const vendor = "test";
|
||||
const vendorBytes = Array.from(enc.encode(vendor));
|
||||
const commentStr = `TITLE=${title}`;
|
||||
const commentBytes = Array.from(enc.encode(commentStr));
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
const commentData = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73, // 0x03 "vorbis"
|
||||
...uint32LE(vendorBytes.length),
|
||||
...vendorBytes,
|
||||
...uint32LE(1),
|
||||
...uint32LE(commentBytes.length),
|
||||
...commentBytes,
|
||||
];
|
||||
const segments = [commentData.length];
|
||||
const page = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
segments.length,
|
||||
...segments,
|
||||
...commentData,
|
||||
];
|
||||
return new Uint8Array(page);
|
||||
}
|
||||
|
||||
function buildWavBuffer(): Uint8Array {
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
const uint16LE = (n: number) => [n & 0xff, (n >> 8) & 0xff];
|
||||
const fmtData = [
|
||||
...uint16LE(1), // PCM
|
||||
...uint16LE(2), // channels
|
||||
...uint32LE(44100), // sample rate
|
||||
...uint32LE(44100 * 2 * 2), // byte rate
|
||||
...uint16LE(4), // block align
|
||||
...uint16LE(16), // bits/sample
|
||||
];
|
||||
const fmtChunk = [
|
||||
0x66,
|
||||
0x6d,
|
||||
0x74,
|
||||
0x20, // "fmt "
|
||||
...uint32LE(fmtData.length),
|
||||
...fmtData,
|
||||
];
|
||||
const riffSize = 4 + fmtChunk.length;
|
||||
return new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
...uint32LE(riffSize),
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45,
|
||||
...fmtChunk,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildMp4Buffer(): Uint8Array {
|
||||
const uint32BE2 = (n: number) => [
|
||||
(n >> 24) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
n & 0xff,
|
||||
];
|
||||
// minimal moov with no ilst
|
||||
const moovData: number[] = [];
|
||||
const moovSize = 8 + moovData.length;
|
||||
return new Uint8Array([
|
||||
...uint32BE2(moovSize),
|
||||
0x6d,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x76,
|
||||
...moovData,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildAiffBuffer(): Uint8Array {
|
||||
const formType = Array.from(new TextEncoder().encode("AIFF"));
|
||||
const formSize = 4;
|
||||
return new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d,
|
||||
(formSize >> 24) & 0xff,
|
||||
(formSize >> 16) & 0xff,
|
||||
(formSize >> 8) & 0xff,
|
||||
formSize & 0xff,
|
||||
...formType,
|
||||
]);
|
||||
}
|
||||
|
||||
describe("extractor dispatch coverage", () => {
|
||||
it("dispatches to ogg parser", { timeout: 30000 }, async () => {
|
||||
const buf = buildOggBuffer("Ogg Test");
|
||||
const track = await extractMetadata(buf);
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("Ogg Test");
|
||||
});
|
||||
|
||||
it("dispatches to wav parser", { timeout: 30000 }, async () => {
|
||||
const buf = buildWavBuffer();
|
||||
const track = await extractMetadata(buf);
|
||||
expect(track.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("dispatches to mp4 parser", { timeout: 30000 }, async () => {
|
||||
const buf = buildMp4Buffer();
|
||||
const track = await extractMetadata(buf);
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("dispatches to aiff parser", { timeout: 30000 }, async () => {
|
||||
const buf = buildAiffBuffer();
|
||||
const track = await extractMetadata(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
});
|
||||
|
||||
it("extractAudioMetadata includes duration when present", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// FLAC buffer with stream info that produces duration
|
||||
const enc = new TextEncoder();
|
||||
const vendor = "test";
|
||||
const vendorBytes = Array.from(enc.encode(vendor));
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
const commentData = [
|
||||
...uint32LE(vendorBytes.length),
|
||||
...vendorBytes,
|
||||
...uint32LE(0),
|
||||
];
|
||||
const streamInfo = new Array(34).fill(0);
|
||||
// sample rate 44100 in bits 80-99
|
||||
streamInfo[10] = 0xac;
|
||||
streamInfo[11] = 0x44;
|
||||
streamInfo[12] = 0x02; // 2 channels
|
||||
// total samples at offset 13-17 (36 bits): set to 44100 for 1 sec
|
||||
streamInfo[17] = 44100 & 0xff;
|
||||
streamInfo[16] = (44100 >> 8) & 0xff;
|
||||
streamInfo[15] = (44100 >> 16) & 0xff;
|
||||
streamInfo[14] = (44100 >> 24) & 0xff;
|
||||
|
||||
const siLen = streamInfo.length;
|
||||
const siBlock = [
|
||||
0x00,
|
||||
(siLen >> 16) & 0xff,
|
||||
(siLen >> 8) & 0xff,
|
||||
siLen & 0xff,
|
||||
...streamInfo,
|
||||
];
|
||||
const cLen = commentData.length;
|
||||
const cBlock = [
|
||||
0x80 | 4,
|
||||
(cLen >> 16) & 0xff,
|
||||
(cLen >> 8) & 0xff,
|
||||
cLen & 0xff,
|
||||
...commentData,
|
||||
];
|
||||
const flacBuf = new Uint8Array([
|
||||
0x66,
|
||||
0x4c,
|
||||
0x61,
|
||||
0x43,
|
||||
...siBlock,
|
||||
...cBlock,
|
||||
]);
|
||||
|
||||
const meta =
|
||||
await MetadataExtractor.getInstance().extractAudioMetadata(flacBuf);
|
||||
expect(meta.format).toBe("flac");
|
||||
if (meta.duration !== undefined) {
|
||||
expect(meta.duration).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("extractAudioMetadata includes bitrate when present", {
|
||||
timeout: 30000,
|
||||
}, async () => {
|
||||
// WAV with fmt chunk sets bitrate — covers the `track.bitrate !== undefined` TRUE branch
|
||||
const buf = buildWavBuffer();
|
||||
const meta =
|
||||
await MetadataExtractor.getInstance().extractAudioMetadata(buf);
|
||||
expect(meta.format).toBe("wav");
|
||||
if (meta.bitrate !== undefined) {
|
||||
expect(meta.bitrate).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import type * as OtelApi from "@opentelemetry/api";
|
||||
import { METADATA_CODES, MetadataError } from "../../errors/metadata-error.js";
|
||||
import { parseAiff } from "../../parsers/aiff-id3/aiff-id3.js";
|
||||
import { parseFlac } from "../../parsers/flac-vorbis/flac-vorbis.js";
|
||||
import { detectAudioFormat } from "../../parsers/format-detector/format-detector.js";
|
||||
import { parseId3v1 } from "../../parsers/id3v1/id3v1.js";
|
||||
import { parseId3v2 } from "../../parsers/id3v2/id3v2.js";
|
||||
import { parseMp4 } from "../../parsers/mp4-atom/mp4-atom.js";
|
||||
import { parseOgg } from "../../parsers/ogg-vorbis/ogg-vorbis.js";
|
||||
import { parseWav } from "../../parsers/wav-riff/wav-riff.js";
|
||||
import type {
|
||||
AudioFormat,
|
||||
AudioMetadata,
|
||||
MetadataExtractionOptions,
|
||||
Track,
|
||||
} from "../../types/metadata.types.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy OTel import — optional tracing, never throws
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let _otel: typeof OtelApi | undefined;
|
||||
async function getOtel(): Promise<typeof OtelApi | undefined> {
|
||||
if (_otel !== undefined) return _otel;
|
||||
_otel = await import("@opentelemetry/api").catch(
|
||||
/* istanbul ignore next -- only reachable in environments without OTel */
|
||||
() => undefined
|
||||
);
|
||||
return _otel;
|
||||
}
|
||||
|
||||
function recordOtelMetrics(
|
||||
otel: typeof OtelApi | undefined,
|
||||
format: string,
|
||||
durationMs: number
|
||||
): void {
|
||||
try {
|
||||
const meter = otel?.metrics.getMeter("@suaveplan/audio-metadata");
|
||||
meter
|
||||
?.createCounter("metadata.extract.total", {
|
||||
description: "Total metadata extractions",
|
||||
})
|
||||
.add(1, { format });
|
||||
meter
|
||||
?.createHistogram("metadata.extract.duration_ms", {
|
||||
description: "Metadata extraction duration in milliseconds",
|
||||
})
|
||||
.record(durationMs, { format });
|
||||
} catch {
|
||||
// OTel errors must never surface to callers
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Core extraction logic
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function applyOptions(track: Track, options: MetadataExtractionOptions): Track {
|
||||
const result = { ...track };
|
||||
if (options.skipCovers) delete result.cover;
|
||||
if (options.skipLyrics) delete result.lyrics;
|
||||
return result;
|
||||
}
|
||||
|
||||
function dispatchParse(
|
||||
buffer: Uint8Array,
|
||||
options: MetadataExtractionOptions
|
||||
): Track {
|
||||
const format = detectAudioFormat(buffer);
|
||||
|
||||
switch (format) {
|
||||
case "id3v2":
|
||||
return parseId3v2(buffer, options);
|
||||
case "id3v1":
|
||||
return parseId3v1(buffer);
|
||||
case "mp4":
|
||||
return parseMp4(buffer, options);
|
||||
case "flac":
|
||||
return parseFlac(buffer);
|
||||
case "ogg":
|
||||
return parseOgg(buffer);
|
||||
case "wav":
|
||||
return parseWav(buffer);
|
||||
case "aiff":
|
||||
return parseAiff(buffer, options);
|
||||
default:
|
||||
throw new MetadataError(
|
||||
`Unsupported audio format: ${format}`,
|
||||
METADATA_CODES.UNSUPPORTED_FORMAT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MetadataExtractor class
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Singleton extractor that dispatches audio buffers to the appropriate parser
|
||||
* and emits OpenTelemetry metrics for observability.
|
||||
*/
|
||||
export class MetadataExtractor {
|
||||
protected static instance: MetadataExtractor;
|
||||
|
||||
protected constructor() {}
|
||||
|
||||
/** Return the shared singleton instance. */
|
||||
static getInstance(): MetadataExtractor {
|
||||
if (!MetadataExtractor.instance) {
|
||||
MetadataExtractor.instance = new MetadataExtractor();
|
||||
}
|
||||
return MetadataExtractor.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from a raw `Uint8Array`.
|
||||
*
|
||||
* Detects the format, dispatches to the appropriate parser, applies options
|
||||
* (skipCovers, skipLyrics, maxCoverSize), and records OTel metrics.
|
||||
*
|
||||
* @throws {MetadataError} with code `UNSUPPORTED_FORMAT` when the buffer
|
||||
* does not match any known audio format.
|
||||
* @throws {MetadataError} with code `PARSE_ERROR` on unexpected parser failures.
|
||||
*/
|
||||
async extractFromBuffer(
|
||||
buffer: Uint8Array,
|
||||
options: MetadataExtractionOptions = {}
|
||||
): Promise<Track> {
|
||||
const start = performance.now();
|
||||
const otel = await getOtel();
|
||||
const format = detectAudioFormat(buffer);
|
||||
|
||||
const track = dispatchParse(buffer, options);
|
||||
const result = applyOptions(track, options);
|
||||
recordOtelMetrics(otel, format, performance.now() - start);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from an `ArrayBuffer` (e.g. from `FileReader` or `fetch`).
|
||||
*/
|
||||
async extractFromArrayBuffer(
|
||||
buffer: ArrayBuffer,
|
||||
options: MetadataExtractionOptions = {}
|
||||
): Promise<Track> {
|
||||
return this.extractFromBuffer(new Uint8Array(buffer), options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract metadata from a File-like object that exposes `arrayBuffer()`.
|
||||
* Works with the browser `File` API and Node.js `Blob`.
|
||||
*/
|
||||
async extractFromFile(
|
||||
file: { arrayBuffer(): Promise<ArrayBuffer> },
|
||||
options: MetadataExtractionOptions = {}
|
||||
): Promise<Track> {
|
||||
let ab: ArrayBuffer;
|
||||
try {
|
||||
ab = await file.arrayBuffer();
|
||||
} catch (err) {
|
||||
throw new MetadataError(
|
||||
`Failed to read file: ${err instanceof Error ? err.message : String(err)}`,
|
||||
METADATA_CODES.FETCH_FAILED
|
||||
);
|
||||
}
|
||||
return this.extractFromArrayBuffer(ab, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the full `AudioMetadata` envelope (format + track) from a buffer.
|
||||
*/
|
||||
async extractAudioMetadata(
|
||||
buffer: Uint8Array,
|
||||
options: MetadataExtractionOptions = {}
|
||||
): Promise<AudioMetadata> {
|
||||
const track = await this.extractFromBuffer(buffer, options);
|
||||
// All parsers always set track.format; cast is safe
|
||||
const format = track.format as AudioFormat;
|
||||
const meta: AudioMetadata = { format, track };
|
||||
if (track.duration !== undefined) meta.duration = track.duration;
|
||||
if (track.bitrate !== undefined) meta.bitrate = track.bitrate;
|
||||
return meta;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Convenience function
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract audio metadata from a `Uint8Array` using the default singleton extractor.
|
||||
*
|
||||
* This is the simplest entry point for one-shot extraction.
|
||||
*
|
||||
* @throws {MetadataError} when the format is unsupported or parsing fails.
|
||||
*/
|
||||
export async function extractMetadata(
|
||||
buffer: Uint8Array,
|
||||
options?: MetadataExtractionOptions
|
||||
): Promise<Track> {
|
||||
return MetadataExtractor.getInstance().extractFromBuffer(buffer, options);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { extractMetadata, MetadataExtractor } from "./extractor";
|
||||
1
packages/media/audio/audio-metadata/src/core/index.ts
Normal file
1
packages/media/audio/audio-metadata/src/core/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { extractMetadata, MetadataExtractor } from "./extractor";
|
||||
5
packages/media/audio/audio-metadata/src/errors/index.ts
Normal file
5
packages/media/audio/audio-metadata/src/errors/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
METADATA_CODES,
|
||||
type MetadataCode,
|
||||
MetadataError,
|
||||
} from "./metadata-error";
|
||||
@@ -0,0 +1,87 @@
|
||||
# metadata-error
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides the stable error code registry (`METADATA_CODES`) and the `MetadataError` class used by every module in `@suaveplan/audio-metadata`. Centralising error identifiers in one place guarantees that callers can branch on a well-known string constant rather than pattern-matching on human-readable messages, which are subject to change.
|
||||
|
||||
The package intentionally does **not** extend `@suaveplan/error`'s `SuaveplanError` — `@suaveplan/audio-metadata` is a leaf package that carries no `@suaveplan/*` runtime dependencies, keeping its install footprint minimal for both Node.js and browser environments.
|
||||
|
||||
## Features
|
||||
|
||||
- Three stable error codes: `UNSUPPORTED_FORMAT`, `PARSE_ERROR`, `FETCH_FAILED`
|
||||
- `MetadataError` extends `Error` and exposes a typed `code` property
|
||||
- `name` is always `"MetadataError"` for easy `instanceof` / `error.name` branching
|
||||
- Exported `MetadataCode` union type for use in catch-block type narrowing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { MetadataError, METADATA_CODES } from "@suaveplan/audio-metadata/errors";
|
||||
|
||||
try {
|
||||
// ... parse audio buffer
|
||||
} catch (err) {
|
||||
if (err instanceof MetadataError && err.code === METADATA_CODES.PARSE_ERROR) {
|
||||
console.error("Malformed audio data:", err.message);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { MetadataError, METADATA_CODES, type MetadataCode } from "@suaveplan/audio-metadata/errors";
|
||||
|
||||
function handleMetadataError(err: MetadataError): never {
|
||||
const actions: Record<MetadataCode, () => never> = {
|
||||
[METADATA_CODES.UNSUPPORTED_FORMAT]: () => { throw new Error("Format not supported by this build"); },
|
||||
[METADATA_CODES.PARSE_ERROR]: () => { throw new Error("Audio data is corrupt"); },
|
||||
[METADATA_CODES.FETCH_FAILED]: () => { throw new Error("Network failure — retry later"); },
|
||||
};
|
||||
return actions[err.code]();
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `METADATA_CODES`
|
||||
|
||||
```ts
|
||||
const METADATA_CODES: {
|
||||
UNSUPPORTED_FORMAT: "METADATA.UNSUPPORTED_FORMAT";
|
||||
PARSE_ERROR: "METADATA.PARSE_ERROR";
|
||||
FETCH_FAILED: "METADATA.FETCH_FAILED";
|
||||
}
|
||||
```
|
||||
|
||||
Immutable `as const` object containing every stable error code string.
|
||||
|
||||
### `MetadataCode`
|
||||
|
||||
```ts
|
||||
type MetadataCode = "METADATA.UNSUPPORTED_FORMAT" | "METADATA.PARSE_ERROR" | "METADATA.FETCH_FAILED";
|
||||
```
|
||||
|
||||
Union of all code values for use in type narrowing.
|
||||
|
||||
### `MetadataError`
|
||||
|
||||
```ts
|
||||
class MetadataError extends Error {
|
||||
readonly code: MetadataCode;
|
||||
constructor(message: string, code: MetadataCode);
|
||||
}
|
||||
```
|
||||
|
||||
| Member | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `message` | `string` | Human-readable description of the failure |
|
||||
| `code` | `MetadataCode` | Stable machine-readable identifier |
|
||||
| `name` | `"MetadataError"` | Always set for `instanceof`-free discrimination |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `name` property is assigned in the constructor body (not via a class-level initialiser) to ensure it survives TypeScript's `super()` call ordering and remains correct when the class is extended. The `code` field is `readonly` to prevent accidental mutation after construction. No external dependencies are used — this file is intentionally dependency-free so it can be imported by any part of the package without creating cycles.
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Stable error codes for the @suaveplan/audio-metadata package.
|
||||
* Values are namespaced strings so they remain unique across the monorepo.
|
||||
*/
|
||||
export const METADATA_CODES = {
|
||||
/** The supplied buffer does not match any supported audio format. */
|
||||
UNSUPPORTED_FORMAT: "METADATA.UNSUPPORTED_FORMAT",
|
||||
/** A parser encountered malformed or truncated data. */
|
||||
PARSE_ERROR: "METADATA.PARSE_ERROR",
|
||||
/** A network request to retrieve remote audio data failed. */
|
||||
FETCH_FAILED: "METADATA.FETCH_FAILED",
|
||||
} as const;
|
||||
|
||||
/** Union of all stable metadata error code values. */
|
||||
export type MetadataCode = (typeof METADATA_CODES)[keyof typeof METADATA_CODES];
|
||||
|
||||
/**
|
||||
* Base error class for all failures raised by @suaveplan/audio-metadata.
|
||||
*
|
||||
* Carries a stable `code` from `METADATA_CODES` alongside the human-readable
|
||||
* `message`, so callers can branch on code rather than string-matching.
|
||||
*/
|
||||
export class MetadataError extends Error {
|
||||
/** Stable code from `METADATA_CODES`. */
|
||||
readonly code: MetadataCode;
|
||||
|
||||
constructor(message: string, code: MetadataCode) {
|
||||
super(message);
|
||||
this.name = "MetadataError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
42
packages/media/audio/audio-metadata/src/index.ts
Normal file
42
packages/media/audio/audio-metadata/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Types
|
||||
|
||||
// Core extractor
|
||||
export {
|
||||
extractMetadata,
|
||||
MetadataExtractor,
|
||||
} from "./core/extractor/extractor.js";
|
||||
export type { MetadataCode } from "./errors/metadata-error.js";
|
||||
// Errors
|
||||
export { METADATA_CODES, MetadataError } from "./errors/metadata-error.js";
|
||||
export { parseAiff } from "./parsers/aiff-id3/aiff-id3.js";
|
||||
export {
|
||||
parseFlac,
|
||||
parseVorbisCommentBlock,
|
||||
} from "./parsers/flac-vorbis/flac-vorbis.js";
|
||||
// Format detector
|
||||
export { detectAudioFormat } from "./parsers/format-detector/format-detector.js";
|
||||
export { parseId3v1 } from "./parsers/id3v1/id3v1.js";
|
||||
// Parsers
|
||||
export { parseId3v2 } from "./parsers/id3v2/id3v2.js";
|
||||
export { parseMp4 } from "./parsers/mp4-atom/mp4-atom.js";
|
||||
export { parseOgg } from "./parsers/ogg-vorbis/ogg-vorbis.js";
|
||||
export { parseWav } from "./parsers/wav-riff/wav-riff.js";
|
||||
export type {
|
||||
AudioFormat,
|
||||
AudioMetadata,
|
||||
CoverArt,
|
||||
MetadataExtractionOptions,
|
||||
Track,
|
||||
} from "./types/metadata.types.js";
|
||||
// Utils
|
||||
export {
|
||||
concatBuffers,
|
||||
readInt32BE,
|
||||
readString,
|
||||
readSyncsafeInt,
|
||||
readUint8,
|
||||
readUint16BE,
|
||||
readUint16LE,
|
||||
readUint32BE,
|
||||
readUint32LE,
|
||||
} from "./utils/buffer-utils.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# aiff-id3
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses AIFF and AIFC audio files, extracting metadata from the embedded `ID3` chunk (using the shared ID3v2 parser) and audio stream properties from the `COMM` chunk. AIFF is a professional audio format common in macOS and audio workstation environments; its metadata is typically carried in an ID3v2 tag rather than a native AIFF tag format.
|
||||
|
||||
The parser handles the 80-bit IEEE 754 extended precision float that AIFF uses for sample rates in the COMM chunk — an unusual format not found in other audio containers.
|
||||
|
||||
## Features
|
||||
|
||||
- Verifies "FORM" + "AIFF"/"AIFC" magic before parsing
|
||||
- AIFF/AIFC chunk tree walker with even-byte padding
|
||||
- COMM chunk decoding: channels, numSampleFrames, sampleRate (80-bit IEEE 754 extended → float)
|
||||
- Locates "ID3 " chunk and delegates to `parseId3v2` for rich metadata extraction
|
||||
- Passes `skipCovers`, `skipLyrics`, and `maxCoverSize` options through to the ID3v2 parser
|
||||
- Duration calculation: `numSampleFrames / sampleRate`
|
||||
- Returns stream info even when no ID3 chunk is present
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseAiff } from "@suaveplan/audio-metadata/parsers/aiff-id3";
|
||||
|
||||
const track = parseAiff(aiffBuffer);
|
||||
console.log(track.title); // "Clair de Lune"
|
||||
console.log(track.sampleRate); // 44100
|
||||
console.log(track.duration); // 300.2 (seconds)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseAiff } from "@suaveplan/audio-metadata/parsers/aiff-id3";
|
||||
|
||||
const track = parseAiff(aiffBuffer, {
|
||||
skipCovers: true,
|
||||
skipLyrics: true,
|
||||
});
|
||||
// Only stream properties and text tags — no cover image decoded
|
||||
console.log(track.channels); // 2
|
||||
console.log(track.bitrate); // undefined (AIFF is lossless, no bitrate)
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseAiff(buffer, options?)`
|
||||
|
||||
```ts
|
||||
function parseAiff(
|
||||
buffer: Uint8Array,
|
||||
options?: {
|
||||
skipCovers?: boolean;
|
||||
skipLyrics?: boolean;
|
||||
maxCoverSize?: number;
|
||||
},
|
||||
): Track
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | Full AIFF/AIFC file buffer |
|
||||
| `options.skipCovers` | `boolean` | Skip cover art extraction |
|
||||
| `options.skipLyrics` | `boolean` | Skip lyrics extraction |
|
||||
| `options.maxCoverSize` | `number` | Maximum cover bytes to accept (default 5 MiB) |
|
||||
|
||||
**Returns:** `Track` object with `format: "aiff"`. Returns `{}` if the buffer does not start with "FORM" + "AIFF"/"AIFC".
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The 80-bit extended precision float format used by AIFF predates IEEE 754 double precision. The exponent is biased by 16383 (not the 1023 used by doubles) and the mantissa does not have an implicit leading 1 bit — the integer bit is explicit at the MSB of the mantissa field. The implementation uses `Math.pow(2, exponent - 16383 - 63)` to place the 64-bit integer mantissa at the correct magnitude. Chunk IDs in AIFF use big-endian sizes, in contrast to RIFF/WAV which uses little-endian — both use the `readUint32BE` / `readUint32LE` helpers respectively to enforce correct byte order.
|
||||
@@ -0,0 +1,407 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseAiff } from "./aiff-id3.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uint32BE(n: number): number[] {
|
||||
return [(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
function aiffChunk(id: string, data: number[]): number[] {
|
||||
const idBytes = Array.from(
|
||||
new TextEncoder().encode(id.padEnd(4, " ").slice(0, 4))
|
||||
);
|
||||
const padded = [...data];
|
||||
if (data.length % 2 !== 0) padded.push(0); // pad to even
|
||||
return [...idBytes, ...uint32BE(data.length), ...padded];
|
||||
}
|
||||
|
||||
function buildAiffFile(chunks: number[][]): Uint8Array {
|
||||
const formType = Array.from(new TextEncoder().encode("AIFF"));
|
||||
const allChunks = chunks.flat();
|
||||
const formSize = 4 + allChunks.length;
|
||||
return new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d, // "FORM"
|
||||
...uint32BE(formSize),
|
||||
...formType,
|
||||
...allChunks,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildMinimalId3v2Header(framesSize: number): number[] {
|
||||
// ID3v2.3 header with syncsafe size
|
||||
const ss = [
|
||||
(framesSize >> 21) & 0x7f,
|
||||
(framesSize >> 14) & 0x7f,
|
||||
(framesSize >> 7) & 0x7f,
|
||||
framesSize & 0x7f,
|
||||
];
|
||||
return [0x49, 0x44, 0x33, 0x03, 0x00, 0x00, ...ss];
|
||||
}
|
||||
|
||||
function buildId3TextFrame(id: string, text: string): number[] {
|
||||
const enc = new TextEncoder();
|
||||
const idBytes = Array.from(enc.encode(id));
|
||||
const textBytes = Array.from(enc.encode(text));
|
||||
const frameData = [0x00, ...textBytes]; // encoding=0
|
||||
return [
|
||||
...idBytes,
|
||||
...uint32BE(frameData.length),
|
||||
0x00,
|
||||
0x00, // flags
|
||||
...frameData,
|
||||
];
|
||||
}
|
||||
|
||||
describe("aiff-id3 parser", () => {
|
||||
it("parses ID3 chunk from AIFF file", { timeout: 5000 }, () => {
|
||||
const titleFrame = buildId3TextFrame("TIT2", "AIFF Title");
|
||||
const id3Data = [
|
||||
...buildMinimalId3v2Header(titleFrame.length),
|
||||
...titleFrame,
|
||||
];
|
||||
const buf = buildAiffFile([aiffChunk("ID3 ", id3Data)]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.title).toBe("AIFF Title");
|
||||
expect(track.format).toBe("aiff");
|
||||
});
|
||||
|
||||
it("parses artist from ID3 chunk", { timeout: 5000 }, () => {
|
||||
const frame = buildId3TextFrame("TPE1", "AIFF Artist");
|
||||
const id3Data = [...buildMinimalId3v2Header(frame.length), ...frame];
|
||||
const buf = buildAiffFile([aiffChunk("ID3 ", id3Data)]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.artist).toBe("AIFF Artist");
|
||||
});
|
||||
|
||||
it("returns format: aiff even without ID3 chunk", { timeout: 5000 }, () => {
|
||||
const buf = buildAiffFile([]); // empty AIFF
|
||||
const track = parseAiff(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
});
|
||||
|
||||
it("returns empty object for non-AIFF buffer", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a,
|
||||
0x0b,
|
||||
]);
|
||||
expect(parseAiff(buf)).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for short buffer", { timeout: 5000 }, () => {
|
||||
expect(parseAiff(new Uint8Array([0x46, 0x4f, 0x52, 0x4d]))).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for FORM without AIFF/AIFC", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x04,
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45, // "WAVE" not AIFF
|
||||
]);
|
||||
expect(parseAiff(buf)).toEqual({});
|
||||
});
|
||||
|
||||
it("handles AIFC format", { timeout: 5000 }, () => {
|
||||
const formType = Array.from(new TextEncoder().encode("AIFC"));
|
||||
const allChunks: number[] = [];
|
||||
const formSize = 4 + allChunks.length;
|
||||
const buf = new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d,
|
||||
...uint32BE(formSize),
|
||||
...formType,
|
||||
]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
});
|
||||
|
||||
it("passes skipCovers option to ID3 parser", { timeout: 5000 }, () => {
|
||||
// Build APIC frame
|
||||
const apicData = [
|
||||
0x00, // encoding
|
||||
0x69,
|
||||
0x6d,
|
||||
0x61,
|
||||
0x67,
|
||||
0x65,
|
||||
0x2f,
|
||||
0x6a,
|
||||
0x70,
|
||||
0x65,
|
||||
0x67,
|
||||
0x00, // "image/jpeg\0"
|
||||
0x03,
|
||||
0x00, // front cover, empty description
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff, // minimal JPEG
|
||||
];
|
||||
const apicFrame = [
|
||||
0x41,
|
||||
0x50,
|
||||
0x49,
|
||||
0x43, // APIC
|
||||
...uint32BE(apicData.length),
|
||||
0x00,
|
||||
0x00,
|
||||
...apicData,
|
||||
];
|
||||
const id3Data = [
|
||||
...buildMinimalId3v2Header(apicFrame.length),
|
||||
...apicFrame,
|
||||
];
|
||||
const buf = buildAiffFile([aiffChunk("ID3 ", id3Data)]);
|
||||
|
||||
const withCover = parseAiff(buf, { skipCovers: false });
|
||||
const noCover = parseAiff(buf, { skipCovers: true });
|
||||
|
||||
if (withCover.cover) {
|
||||
expect(noCover.cover).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses COMM chunk for sample rate and channels", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// COMM chunk: channels(2BE) + numSampleFrames(4BE) + sampleSize(2BE) + sampleRate(80-bit IEEE)
|
||||
// 44100 Hz as 80-bit extended: exponent=16398 (0x400E), mantissa=0xAC44000000000000
|
||||
const ieee754_44100 = [
|
||||
0x40,
|
||||
0x0e, // exponent biased: 16398 = 16383 + 15
|
||||
0xac,
|
||||
0x44,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // mantissa (44100 << 49)
|
||||
];
|
||||
const commData = [
|
||||
0x00,
|
||||
0x02, // channels: 2
|
||||
0x00,
|
||||
0x00,
|
||||
0xac,
|
||||
0x44, // numSampleFrames: 44100
|
||||
0x00,
|
||||
0x10, // sampleSize: 16
|
||||
...ieee754_44100,
|
||||
];
|
||||
const buf = buildAiffFile([aiffChunk("COMM", commData)]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
expect(track.channels).toBe(2);
|
||||
// Sample rate should be around 44100
|
||||
if (track.sampleRate !== undefined) {
|
||||
expect(track.sampleRate).toBeGreaterThan(40000);
|
||||
expect(track.sampleRate).toBeLessThan(50000);
|
||||
}
|
||||
});
|
||||
|
||||
it("ignores COMM chunk smaller than 18 bytes", { timeout: 5000 }, () => {
|
||||
// COMM chunk must be >= 18 bytes to parse; smaller should be ignored
|
||||
const smallCommData = [0x00, 0x02, 0x00, 0x00, 0x00, 0x00]; // only 6 bytes
|
||||
const buf = buildAiffFile([aiffChunk("COMM", smallCommData)]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
// channels/sampleRate should not be set since COMM was too small
|
||||
expect(track.channels).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles COMM with all-zero IEEE 754 extended (sampleRate = 0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// COMM chunk with 18+ bytes but all-zero IEEE 754 → sampleRate = 0 (not set)
|
||||
const commData = [
|
||||
0x00,
|
||||
0x02, // channels: 2
|
||||
0x00,
|
||||
0x00,
|
||||
0xac,
|
||||
0x44, // numSampleFrames: 44100
|
||||
0x00,
|
||||
0x10, // sampleSize: 16
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // all-zero 80-bit = 0.0
|
||||
];
|
||||
const buf = buildAiffFile([aiffChunk("COMM", commData)]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
// sampleRate = 0 means it's not assigned (> 0 check)
|
||||
expect(track.sampleRate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles truncated buffer where COMM chunk data is partially out of bounds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build an AIFF with a COMM chunk where size claims 18 but the buffer is cut short
|
||||
// so the IEEE 754 extended bytes at offset+8 are beyond the buffer
|
||||
const commData = [
|
||||
0x00,
|
||||
0x02, // channels
|
||||
0x00,
|
||||
0x00,
|
||||
0xac,
|
||||
0x44, // numSampleFrames
|
||||
0x00,
|
||||
0x10, // sampleSize
|
||||
0x40,
|
||||
0x0e, // start of IEEE 754 (only 2 bytes — truncated)
|
||||
]; // 10 bytes instead of 18
|
||||
// We need to set chunk size to 18 but only provide 10 bytes
|
||||
// Use manual chunk construction with inflated size claim
|
||||
const chunkId = Array.from(new TextEncoder().encode("COMM"));
|
||||
// Claim size=18, but only 10 bytes of data
|
||||
const chunkSizeClaim = [
|
||||
(0 >> 24) & 0xff,
|
||||
(18 >> 16) & 0xff,
|
||||
(18 >> 8) & 0xff,
|
||||
18 & 0xff,
|
||||
];
|
||||
const chunk = [...chunkId, ...chunkSizeClaim, ...commData]; // 18-byte claim, 10-byte data
|
||||
const formType = Array.from(new TextEncoder().encode("AIFF"));
|
||||
const formSize = 4 + chunk.length;
|
||||
const buf = new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d,
|
||||
(formSize >> 24) & 0xff,
|
||||
(formSize >> 16) & 0xff,
|
||||
(formSize >> 8) & 0xff,
|
||||
formSize & 0xff,
|
||||
...formType,
|
||||
...chunk,
|
||||
]);
|
||||
// Should not throw, just return minimal AIFF
|
||||
const track = parseAiff(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
});
|
||||
|
||||
it("handles COMM chunk header exactly at buffer end (channels bytes undefined → ?? 0)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build AIFF where COMM chunk header (8 bytes) fills buffer exactly — no data bytes.
|
||||
// chunk.size=18 satisfies chunk.size >= 18, but buffer[dataOffset] is undefined → ?? 0 fires.
|
||||
const formType = Array.from(new TextEncoder().encode("AIFF"));
|
||||
// COMM chunk: 4-byte ID + 4-byte size (claims 18), 0 bytes of data
|
||||
const commId = Array.from(new TextEncoder().encode("COMM"));
|
||||
const commSize = [0x00, 0x00, 0x00, 0x12]; // 18
|
||||
const commChunkHeader = [...commId, ...commSize]; // 8 bytes
|
||||
const formSize = 4 + commChunkHeader.length; // 4 ("AIFF") + 8 = 12
|
||||
const buf = new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d, // "FORM"
|
||||
(formSize >> 24) & 0xff,
|
||||
(formSize >> 16) & 0xff,
|
||||
(formSize >> 8) & 0xff,
|
||||
formSize & 0xff,
|
||||
...formType,
|
||||
...commChunkHeader,
|
||||
// No data bytes — dataOffset = 20 = buffer.length, so buffer[20] is undefined
|
||||
]);
|
||||
const track = parseAiff(buf);
|
||||
// channels stays 0 (?? 0 fires), sampleRate stays 0 — no channels/sampleRate set
|
||||
expect(track.format).toBe("aiff");
|
||||
expect(track.channels).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns negative sample rate via IEEE 754 sign bit (parseIeee754Extended -f path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Set sign bit in IEEE 754 extended: byte[0] = 0xC0 (sign=1, exp high bits)
|
||||
// exponent = (0xC0 & 0x7f) << 8 | 0x0E = 0x400E = 16398 = 16383+15
|
||||
// mantissa = 0xAC44... same as 44100 but with sign bit = negative
|
||||
const ieee754_neg44100 = [
|
||||
0xc0,
|
||||
0x0e, // sign bit set + exponent → negative 44100
|
||||
0xac,
|
||||
0x44,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
];
|
||||
const commData = [
|
||||
0x00,
|
||||
0x02, // channels: 2
|
||||
0x00,
|
||||
0x00,
|
||||
0xac,
|
||||
0x44, // numSampleFrames: 44100
|
||||
0x00,
|
||||
0x10, // sampleSize: 16
|
||||
...ieee754_neg44100,
|
||||
];
|
||||
const buf = buildAiffFile([aiffChunk("COMM", commData)]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.format).toBe("aiff");
|
||||
// sampleRate < 0 so > 0 check fails — sampleRate not set on track
|
||||
expect(track.sampleRate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns format aiff with stream info when ID3 chunk follows COMM", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const ieee754_44100 = [
|
||||
0x40, 0x0e, 0xac, 0x44, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
];
|
||||
const commData = [
|
||||
0x00,
|
||||
0x02,
|
||||
0x00,
|
||||
0x00,
|
||||
0xac,
|
||||
0x44, // 44100 sample frames
|
||||
0x00,
|
||||
0x10,
|
||||
...ieee754_44100,
|
||||
];
|
||||
const titleFrame = buildId3TextFrame("TIT2", "AIFF Timed");
|
||||
const id3Data = [
|
||||
...buildMinimalId3v2Header(titleFrame.length),
|
||||
...titleFrame,
|
||||
];
|
||||
const buf = buildAiffFile([
|
||||
aiffChunk("COMM", commData),
|
||||
aiffChunk("ID3 ", id3Data),
|
||||
]);
|
||||
const track = parseAiff(buf);
|
||||
expect(track.title).toBe("AIFF Timed");
|
||||
expect(track.channels).toBe(2);
|
||||
if (track.duration !== undefined) {
|
||||
expect(track.duration).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { Track } from "../../types/metadata.types.js";
|
||||
import { readString, readUint32BE } from "../../utils/buffer-utils.js";
|
||||
import { parseId3v2 } from "../id3v2/id3v2.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AIFF chunk walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AiffChunk {
|
||||
id: string;
|
||||
size: number;
|
||||
dataOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate AIFF/AIFC chunks within the FORM container.
|
||||
*
|
||||
* AIFF chunks: 4-byte ASCII id + 4-byte big-endian size + data.
|
||||
* Chunks are padded to even byte boundaries.
|
||||
*/
|
||||
function* iterAiffChunks(
|
||||
buf: Uint8Array,
|
||||
start: number,
|
||||
end: number
|
||||
): Generator<AiffChunk> {
|
||||
let pos = start;
|
||||
while (pos + 8 <= end) {
|
||||
const id = readString(buf, pos, 4, "latin1");
|
||||
const size = readUint32BE(buf, pos + 4);
|
||||
yield { id, size, dataOffset: pos + 8 };
|
||||
pos += 8 + size + (size % 2); // pad to even
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse AIFF/AIFC audio files, extracting metadata from the embedded "ID3 "
|
||||
* chunk (if present) via the ID3v2 parser.
|
||||
*
|
||||
* Also reads the COMM chunk for audio stream properties (sample rate, channels,
|
||||
* sample frames count → duration).
|
||||
*
|
||||
* Returns a normalised `Track`. Returns an empty object if the buffer
|
||||
* does not start with "FORM" + "AIFF"/"AIFC".
|
||||
*/
|
||||
export function parseAiff(
|
||||
buffer: Uint8Array,
|
||||
options?: {
|
||||
skipCovers?: boolean;
|
||||
skipLyrics?: boolean;
|
||||
maxCoverSize?: number;
|
||||
}
|
||||
): Track {
|
||||
if (buffer.length < 12) return {};
|
||||
if (
|
||||
buffer[0] !== 0x46 || // F
|
||||
buffer[1] !== 0x4f || // O
|
||||
buffer[2] !== 0x52 || // R
|
||||
buffer[3] !== 0x4d // M
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const form8 = buffer[8];
|
||||
const form9 = buffer[9];
|
||||
const form10 = buffer[10];
|
||||
const form11 = buffer[11];
|
||||
const isAiff =
|
||||
form8 === 0x41 && form9 === 0x49 && form10 === 0x46 && form11 === 0x46;
|
||||
const isAifc =
|
||||
form8 === 0x41 && form9 === 0x49 && form10 === 0x46 && form11 === 0x43;
|
||||
if (!isAiff && !isAifc) return {};
|
||||
|
||||
const formSize = readUint32BE(buffer, 4);
|
||||
const end = Math.min(8 + formSize, buffer.length);
|
||||
|
||||
let track: Track = { format: "aiff" };
|
||||
let sampleRate = 0;
|
||||
let channels = 0;
|
||||
let numSampleFrames = 0;
|
||||
|
||||
for (const chunk of iterAiffChunks(buffer, 12, end)) {
|
||||
if (chunk.id === "COMM") {
|
||||
// COMM chunk: channels(2BE) + numSampleFrames(4BE) + sampleSize(2BE) + sampleRate(80-bit IEEE 754 extended)
|
||||
if (chunk.size >= 18) {
|
||||
channels =
|
||||
((buffer[chunk.dataOffset] ?? 0) << 8) |
|
||||
(buffer[chunk.dataOffset + 1] ?? 0);
|
||||
numSampleFrames = readUint32BE(buffer, chunk.dataOffset + 2);
|
||||
sampleRate = parseIeee754Extended(buffer, chunk.dataOffset + 8);
|
||||
}
|
||||
}
|
||||
|
||||
// "ID3 " chunk (with trailing space) — some encoders write "ID3 "
|
||||
if (chunk.id === "ID3 " || chunk.id === "id3 ") {
|
||||
const id3Data = buffer.subarray(
|
||||
chunk.dataOffset,
|
||||
chunk.dataOffset + chunk.size
|
||||
);
|
||||
track = parseId3v2(id3Data, options);
|
||||
track.format = "aiff";
|
||||
// Apply stream info after ID3 parse
|
||||
if (channels > 0) track.channels = channels;
|
||||
if (sampleRate > 0) track.sampleRate = sampleRate;
|
||||
if (sampleRate > 0 && numSampleFrames > 0) {
|
||||
track.duration = numSampleFrames / sampleRate;
|
||||
}
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
// No ID3 chunk found — return stream info only
|
||||
if (channels > 0) track.channels = channels;
|
||||
if (sampleRate > 0) track.sampleRate = sampleRate;
|
||||
if (sampleRate > 0 && numSampleFrames > 0) {
|
||||
track.duration = numSampleFrames / sampleRate;
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an 80-bit IEEE 754 extended precision float from a buffer.
|
||||
*
|
||||
* AIFF uses this format for sample rates in the COMM chunk.
|
||||
* The format: 1 sign bit, 15 exponent bits (biased by 16383), 64 mantissa bits.
|
||||
*/
|
||||
function parseIeee754Extended(buf: Uint8Array, offset: number): number {
|
||||
if (offset + 10 > buf.length) return 0;
|
||||
// buf[offset] is always defined (offset+10 <= buf.length guaranteed); ?? 0 is defensive only
|
||||
const b0 = buf[offset] as number;
|
||||
const b1 = buf[offset + 1] as number;
|
||||
const exponent = ((b0 & 0x7f) << 8) | b1;
|
||||
const mantissaHigh = readUint32BE(buf, offset + 2);
|
||||
const mantissaLow = readUint32BE(buf, offset + 6);
|
||||
if (exponent === 0 && mantissaHigh === 0 && mantissaLow === 0) return 0;
|
||||
const f =
|
||||
(mantissaHigh * 0x100000000 + mantissaLow) *
|
||||
2 ** (exponent - 16383 - 63);
|
||||
return b0 & 0x80 ? -f : f;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { parseAiff } from "./aiff-id3";
|
||||
@@ -0,0 +1,77 @@
|
||||
# flac-vorbis
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses FLAC (Free Lossless Audio Codec) files by reading their METADATA_BLOCK sequence and decoding the VORBIS_COMMENT block into a normalised `Track`. FLAC metadata follows the Xiph.org Vorbis comment standard — a UTF-8 key/value pair format also used by Ogg Vorbis — making the comment decoder reusable across both formats.
|
||||
|
||||
In addition to tag data, the parser also extracts audio stream properties (sample rate, channel count, duration) from the mandatory STREAMINFO block.
|
||||
|
||||
## Features
|
||||
|
||||
- Verifies the 4-byte "fLaC" magic before parsing
|
||||
- Reads STREAMINFO for sample rate, channel count, and total sample count (duration calculation)
|
||||
- Iterates all METADATA_BLOCKs respecting the last-block flag
|
||||
- Decodes VORBIS_COMMENT (block type 4): vendor string, comment count, and `KEY=VALUE` pairs
|
||||
- Maps standard Vorbis comment keys: TITLE, ARTIST, ALBUM, ALBUMARTIST, DATE, TRACKNUMBER, TRACKTOTAL/TOTALTRACKS, DISCNUMBER, DISCTOTAL/TOTALDISCS, GENRE, COMMENT, LYRICS/UNSYNCEDLYRICS
|
||||
- Multiple values for the same key are stored as arrays in `rawTags`
|
||||
- Exports `parseVorbisCommentBlock` for reuse by the Ogg parser
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseFlac } from "@suaveplan/audio-metadata/parsers/flac-vorbis";
|
||||
|
||||
const track = parseFlac(flacBuffer);
|
||||
console.log(track.title); // "Wish You Were Here"
|
||||
console.log(track.sampleRate); // 44100
|
||||
console.log(track.duration); // 334.5 (seconds)
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseFlac, parseVorbisCommentBlock } from "@suaveplan/audio-metadata/parsers/flac-vorbis";
|
||||
|
||||
// Parse standalone Vorbis comment data (e.g. from a custom stream)
|
||||
const commentBlock = new Uint8Array([...]);
|
||||
const track = parseVorbisCommentBlock(commentBlock);
|
||||
|
||||
// Full FLAC parse
|
||||
const flacTrack = parseFlac(fullFlacBuffer);
|
||||
if (flacTrack.rawTags?.["REPLAYGAIN_TRACK_GAIN"]) {
|
||||
console.log("ReplayGain:", flacTrack.rawTags["REPLAYGAIN_TRACK_GAIN"]);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseFlac(buffer)`
|
||||
|
||||
```ts
|
||||
function parseFlac(buffer: Uint8Array): Track
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | Full FLAC file buffer |
|
||||
|
||||
**Returns:** `Track` object with `format: "flac"`. Returns `{}` if the buffer does not start with "fLaC".
|
||||
|
||||
### `parseVorbisCommentBlock(data)`
|
||||
|
||||
```ts
|
||||
function parseVorbisCommentBlock(data: Uint8Array): Track
|
||||
```
|
||||
|
||||
Parses raw Vorbis comment binary data (without the FLAC block header) and returns a `Track`. Used internally by the Ogg parser. Does not set `format`.
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `data` | `Uint8Array` | Raw comment block bytes starting with the vendor string length |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
FLAC METADATA_BLOCK headers use a 24-bit big-endian size (3 bytes at positions 1–3), not the more common 32-bit size. The STREAMINFO sample rate occupies bits 80–99 of the block data — reading it requires bit-shifting across byte boundaries. The total samples field is 36 bits, stored as a 4-bit high nibble followed by a 32-bit value; the implementation handles this by reading the high nibble separately and multiplying by 2³². All multi-value Vorbis tags (e.g. multiple ARTISTs) are preserved as `string[]` in `rawTags` while `track.artist` holds only the first value, consistent with the behaviour of popular audio players.
|
||||
@@ -0,0 +1,445 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseFlac, parseVorbisCommentBlock } from "./flac-vorbis.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uint32LE(n: number): number[] {
|
||||
return [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff];
|
||||
}
|
||||
|
||||
function vorbisCommentBytes(comments: string[]): number[] {
|
||||
const vendor = "test";
|
||||
const vendorBytes = Array.from(new TextEncoder().encode(vendor));
|
||||
const out: number[] = [
|
||||
...uint32LE(vendorBytes.length),
|
||||
...vendorBytes,
|
||||
...uint32LE(comments.length),
|
||||
];
|
||||
for (const c of comments) {
|
||||
const bytes = Array.from(new TextEncoder().encode(c));
|
||||
out.push(...uint32LE(bytes.length), ...bytes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function flacMetadataBlock(
|
||||
type: number,
|
||||
data: number[],
|
||||
isLast: boolean
|
||||
): number[] {
|
||||
const header = (isLast ? 0x80 : 0x00) | (type & 0x7f);
|
||||
const len = data.length;
|
||||
return [header, (len >> 16) & 0xff, (len >> 8) & 0xff, len & 0xff, ...data];
|
||||
}
|
||||
|
||||
function buildMinimalFlac(comments: string[]): Uint8Array {
|
||||
const magic = [0x66, 0x4c, 0x61, 0x43]; // "fLaC"
|
||||
|
||||
// Minimal STREAMINFO block (34 bytes)
|
||||
const streamInfo = new Array(34).fill(0);
|
||||
// sample rate: 44100 = 0xAC44
|
||||
// bytes 10-12 pack 20-bit sample rate: 44100 = 0b1010_1100_0100_0100
|
||||
// bits 80-99: sample rate (20 bits)
|
||||
// byte 10 = bits 80-87 = 0xAC (upper 8 of 20)
|
||||
// byte 11 = bits 88-95 = 0x44 (middle 8 of 20)
|
||||
// byte 12 bits 7-4 = bits 96-99 = 0x00 (lower 4 of 20)
|
||||
streamInfo[10] = 0xac; // 44100 >> 12
|
||||
streamInfo[11] = 0x44; // (44100 >> 4) & 0xff
|
||||
streamInfo[12] = 0x00; // ((44100 & 0xf) << 4) | (channels-1 << 1) | (bitsPerSample-1 >> 4)
|
||||
// channels: 2 -> stored as 1 (0-based) in bits 100-102
|
||||
// byte 12 bits 3-1 = channels-1 = 1 -> 0x02
|
||||
streamInfo[12] = 0x02; // (44100 & 0xf) << 4 = 0, channels-1 = 1 -> 0b0000_0010
|
||||
|
||||
const streamInfoBlock = flacMetadataBlock(0, streamInfo, false);
|
||||
const commentData = vorbisCommentBytes(comments);
|
||||
const commentBlock = flacMetadataBlock(4, commentData, true);
|
||||
|
||||
return new Uint8Array([...magic, ...streamInfoBlock, ...commentBlock]);
|
||||
}
|
||||
|
||||
describe("flac-vorbis parser", () => {
|
||||
it("parses basic Vorbis comment tags", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac([
|
||||
"TITLE=My FLAC Track",
|
||||
"ARTIST=Test Artist",
|
||||
"ALBUM=Test Album",
|
||||
"DATE=2022",
|
||||
]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.title).toBe("My FLAC Track");
|
||||
expect(track.artist).toBe("Test Artist");
|
||||
expect(track.album).toBe("Test Album");
|
||||
expect(track.year).toBe(2022);
|
||||
expect(track.format).toBe("flac");
|
||||
});
|
||||
|
||||
it("parses TRACKNUMBER and TRACKTOTAL", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["TRACKNUMBER=4", "TRACKTOTAL=10"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.trackNumber).toBe(4);
|
||||
expect(track.totalTracks).toBe(10);
|
||||
});
|
||||
|
||||
it("parses TOTALTRACKS as alias for TRACKTOTAL", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["TRACKNUMBER=2", "TOTALTRACKS=8"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.totalTracks).toBe(8);
|
||||
});
|
||||
|
||||
it("parses DISCNUMBER and DISCTOTAL", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["DISCNUMBER=2", "DISCTOTAL=3"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.discNumber).toBe(2);
|
||||
expect(track.totalDiscs).toBe(3);
|
||||
});
|
||||
|
||||
it("parses GENRE and COMMENT", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["GENRE=Jazz", "COMMENT=Cool track"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.genre).toBe("Jazz");
|
||||
expect(track.comment).toBe("Cool track");
|
||||
});
|
||||
|
||||
it("parses ALBUMARTIST", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["ALBUMARTIST=Various Artists"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.albumArtist).toBe("Various Artists");
|
||||
});
|
||||
|
||||
it("returns format: flac", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac([]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.format).toBe("flac");
|
||||
});
|
||||
|
||||
it("returns empty object for non-FLAC buffer", { timeout: 5000 }, () => {
|
||||
expect(parseFlac(new Uint8Array([0x00, 0x01, 0x02, 0x03]))).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for too-short buffer", { timeout: 5000 }, () => {
|
||||
expect(parseFlac(new Uint8Array([0x66, 0x4c]))).toEqual({});
|
||||
});
|
||||
|
||||
it("returns format flac with streaminfo but no vorbis comment block", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// FLAC with only STREAMINFO (last block) — no VORBIS_COMMENT block
|
||||
const streamInfo = new Array(34).fill(0);
|
||||
streamInfo[10] = 0xac;
|
||||
streamInfo[11] = 0x44;
|
||||
streamInfo[12] = 0x02; // channels=2
|
||||
const streamInfoBlock = flacMetadataBlock(0, streamInfo, true); // isLast=true
|
||||
const buf = new Uint8Array([
|
||||
0x66,
|
||||
0x4c,
|
||||
0x61,
|
||||
0x43,
|
||||
...streamInfoBlock,
|
||||
]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.format).toBe("flac");
|
||||
// sampleRate and channels may be populated from streaminfo
|
||||
if (track.sampleRate !== undefined) {
|
||||
expect(track.sampleRate).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("parses TOTALDISCS alias", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["DISCNUMBER=1", "TOTALDISCS=4"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.discNumber).toBe(1);
|
||||
expect(track.totalDiscs).toBe(4);
|
||||
});
|
||||
|
||||
it("parses LYRICS tag", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["LYRICS=Some lyrics here"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.lyrics).toBe("Some lyrics here");
|
||||
});
|
||||
|
||||
it("parses UNSYNCEDLYRICS alias", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalFlac(["UNSYNCEDLYRICS=Unsynced lyrics"]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.lyrics).toBe("Unsynced lyrics");
|
||||
});
|
||||
|
||||
it("computes duration when streaminfo has non-zero totalSamples", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build FLAC with STREAMINFO where totalSamples > 0 so duration branch fires
|
||||
const streamInfo = new Array(34).fill(0);
|
||||
// sampleRate = 44100 encoded as 20-bit value in bits 80-99
|
||||
// 44100 = 0xAC44; bits 80-87 = 0xAC, bits 88-95 = 0x44, bits 96-99 = 0x0
|
||||
streamInfo[10] = 0xac; // bits 80-87
|
||||
streamInfo[11] = 0x44; // bits 88-95
|
||||
// byte 12: bits 96-99 (sampleRate) | bits 100-102 (channels-1) | bits 103-107 (bitsPerSample-1)
|
||||
// channels=2 → channels-1=1 → stored in bits 100-102 → byte12 bits 3-1 → 0x02
|
||||
streamInfo[12] = 0x02;
|
||||
// totalSamples: 36 bits in bytes 13-17
|
||||
// totalSamples = 44100 (0xAC44)
|
||||
// byte13 = high nibble from byte12 (bits 108-111 of bitsPerSample) + bits 108-111 totalSamples
|
||||
// bits 108-143 = totalSamples; byte 13 lower 4 bits = totalSamples bits 32-35 (all 0 for small values)
|
||||
// bytes 14-17 = totalSamples bits 0-31 = 44100 = 0x0000AC44
|
||||
streamInfo[13] = 0x00;
|
||||
streamInfo[14] = 0x00;
|
||||
streamInfo[15] = 0x00;
|
||||
streamInfo[16] = 0xac;
|
||||
streamInfo[17] = 0x44;
|
||||
const streamInfoBlock = flacMetadataBlock(0, streamInfo, false);
|
||||
const commentData = vorbisCommentBytes(["TITLE=Duration Test"]);
|
||||
const commentBlock = flacMetadataBlock(4, commentData, true);
|
||||
const buf = new Uint8Array([
|
||||
0x66,
|
||||
0x4c,
|
||||
0x61,
|
||||
0x43,
|
||||
...streamInfoBlock,
|
||||
...commentBlock,
|
||||
]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.title).toBe("Duration Test");
|
||||
expect(track.format).toBe("flac");
|
||||
if (track.duration !== undefined) {
|
||||
expect(track.duration).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("breaks when block claims length beyond buffer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A FLAC block that claims blockLen > remaining buffer size triggers the break at line 47
|
||||
// Build a FLAC header + one block where blockLen is way too large
|
||||
const magic = [0x66, 0x4c, 0x61, 0x43]; // "fLaC"
|
||||
// Block: type=1 (PADDING), NOT last block, length=9999 (way beyond buffer)
|
||||
const blockHeader = [
|
||||
0x01, // isLast=0, blockType=1 (padding)
|
||||
(9999 >> 16) & 0xff,
|
||||
(9999 >> 8) & 0xff,
|
||||
9999 & 0xff,
|
||||
];
|
||||
const buf = new Uint8Array([...magic, ...blockHeader]);
|
||||
// blockLen=9999, but buffer only has 8 bytes total — pos + blockLen > buffer.length → break
|
||||
const track = parseFlac(buf);
|
||||
expect(track.format).toBe("flac");
|
||||
});
|
||||
|
||||
it("returns format flac with zero sampleRate/channels when streaminfo is all zeros", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// STREAMINFO with all-zero data → sampleRate=0, channels=1 (0+1=1 from formula)
|
||||
// Actually channels = (((0)>>1)&0x07)+1 = 1, which is > 0 and would get set
|
||||
// To get channels=0 effectively, we need streamInfoChannels=0, but formula adds 1
|
||||
// So instead: use a FLAC with NO STREAMINFO block to get sampleRate=0, channels=0
|
||||
const commentData = vorbisCommentBytes(["TITLE=NoStreamInfo"]);
|
||||
// Block type=4 (VORBIS_COMMENT), isLast=true, no preceding STREAMINFO
|
||||
const commentBlock = flacMetadataBlock(4, commentData, true);
|
||||
const buf = new Uint8Array([0x66, 0x4c, 0x61, 0x43, ...commentBlock]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.title).toBe("NoStreamInfo");
|
||||
expect(track.format).toBe("flac");
|
||||
// streamInfoSampleRate=0 and streamInfoChannels=0 → neither sampleRate nor channels set
|
||||
expect(track.sampleRate).toBeUndefined();
|
||||
expect(track.channels).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns format flac with zero streaminfo when no VORBIS_COMMENT (FALSE branches of sampleRate/channels)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// FLAC with STREAMINFO that has sampleRate=0 (all-zero streamInfo) and no VORBIS_COMMENT
|
||||
// This exercises the FALSE branches of lines 85-86
|
||||
const streamInfo = new Array(34).fill(0); // all zeros → sampleRate=0, channels=1
|
||||
// channels formula: (((b[12] >> 1) & 0x07) + 1) with b[12]=0 → channels = (0+1) = 1
|
||||
// sampleRate formula with b[10]=b[11]=b[12]=0 → sampleRate = 0
|
||||
const streamInfoBlock = flacMetadataBlock(0, streamInfo, true);
|
||||
const buf = new Uint8Array([
|
||||
0x66,
|
||||
0x4c,
|
||||
0x61,
|
||||
0x43,
|
||||
...streamInfoBlock,
|
||||
]);
|
||||
const track = parseFlac(buf);
|
||||
expect(track.format).toBe("flac");
|
||||
// sampleRate = 0 → not set on track (FALSE branch of line 85)
|
||||
expect(track.sampleRate).toBeUndefined();
|
||||
// channels = 1 (never 0 from formula) → IS set; but that's fine
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseVorbisCommentBlock", () => {
|
||||
it("parses standalone comment block", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array(
|
||||
vorbisCommentBytes(["TITLE=Standalone", "ARTIST=Solo"])
|
||||
);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track.title).toBe("Standalone");
|
||||
expect(track.artist).toBe("Solo");
|
||||
});
|
||||
|
||||
it("returns empty object for too-short data", { timeout: 5000 }, () => {
|
||||
expect(parseVorbisCommentBlock(new Uint8Array([0x00, 0x00]))).toEqual(
|
||||
{}
|
||||
);
|
||||
});
|
||||
|
||||
it("handles multiple values for same key as array in rawTags", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data = new Uint8Array(
|
||||
vorbisCommentBytes(["ARTIST=First", "ARTIST=Second"])
|
||||
);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(Array.isArray(track.rawTags?.["ARTIST"])).toBe(true);
|
||||
expect(track.artist).toBe("First");
|
||||
});
|
||||
|
||||
it("ignores non-numeric TRACKTOTAL", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array(
|
||||
vorbisCommentBytes(["TRACKNUMBER=3", "TRACKTOTAL=notanumber"])
|
||||
);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track.trackNumber).toBe(3);
|
||||
expect(track.totalTracks).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-numeric DISCNUMBER", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array(vorbisCommentBytes(["DISCNUMBER=abc"]));
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track.discNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-numeric DISCTOTAL", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array(
|
||||
vorbisCommentBytes(["DISCNUMBER=1", "DISCTOTAL=x"])
|
||||
);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track.discNumber).toBe(1);
|
||||
expect(track.totalDiscs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-numeric DATE (NaN year)", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array(vorbisCommentBytes(["DATE=abcd"]));
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track.year).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-numeric TRACKNUMBER", { timeout: 5000 }, () => {
|
||||
const data = new Uint8Array(vorbisCommentBytes(["TRACKNUMBER=abc"]));
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track.trackNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips comment without equals sign", { timeout: 5000 }, () => {
|
||||
// A comment that has no '=' — should be skipped (no key=value)
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
const enc = new TextEncoder();
|
||||
const vendor = "test";
|
||||
const vendorBytes = Array.from(enc.encode(vendor));
|
||||
const invalidComment = "NOTAKEYVALUE"; // no '='
|
||||
const invalidBytes = Array.from(enc.encode(invalidComment));
|
||||
const data = new Uint8Array([
|
||||
...uint32LE(vendorBytes.length),
|
||||
...vendorBytes,
|
||||
...uint32LE(1), // 1 comment
|
||||
...uint32LE(invalidBytes.length),
|
||||
...invalidBytes,
|
||||
]);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
// No fields should be set
|
||||
expect(track.title).toBeUndefined();
|
||||
expect(track.artist).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns empty when vendor string consumes all data (no room for count)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Set vendor length to a huge value that causes pos + 4 > data.length
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
// vendor length = 100, but only 10 bytes of actual vendor data
|
||||
const data = new Uint8Array([
|
||||
...uint32LE(100), // claimed vendor len = 100
|
||||
0x41,
|
||||
0x42,
|
||||
0x43,
|
||||
0x44, // only 4 bytes of vendor data
|
||||
]);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track).toEqual({});
|
||||
});
|
||||
|
||||
it("handles three or more values for same key (array.push path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const data = new Uint8Array(
|
||||
vorbisCommentBytes(["ARTIST=One", "ARTIST=Two", "ARTIST=Three"])
|
||||
);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
const artistRaw = track.rawTags?.["ARTIST"];
|
||||
expect(Array.isArray(artistRaw)).toBe(true);
|
||||
expect((artistRaw as string[]).length).toBe(3);
|
||||
expect(track.artist).toBe("One");
|
||||
});
|
||||
|
||||
it("breaks at line 119 when commentCount exceeds buffer space", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a vorbis comment block where commentCount=5 but there's no space for comments
|
||||
// After reading vendor string and commentCount, pos+4 > data.length immediately
|
||||
const enc = new TextEncoder();
|
||||
const vendor = "x";
|
||||
const vendorBytes = Array.from(enc.encode(vendor));
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
// Build data with only vendor string + commentCount, no comment length bytes
|
||||
const data = new Uint8Array([
|
||||
...uint32LE(vendorBytes.length), // vendorLen = 1
|
||||
...vendorBytes, // "x"
|
||||
...uint32LE(5), // commentCount = 5 (but no room for any comment)
|
||||
// No comment data — pos+4 > data.length triggers break
|
||||
]);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
// Should complete without error, just no comments parsed
|
||||
expect(track.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("breaks at line 122 when commentLen exceeds remaining data", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a vorbis comment block where commentLen claims more bytes than available
|
||||
const enc = new TextEncoder();
|
||||
const vendor = "x";
|
||||
const vendorBytes = Array.from(enc.encode(vendor));
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
const data = new Uint8Array([
|
||||
...uint32LE(vendorBytes.length),
|
||||
...vendorBytes,
|
||||
...uint32LE(1), // commentCount = 1
|
||||
...uint32LE(100), // commentLen = 100 (but only a few bytes follow)
|
||||
0x41,
|
||||
0x42, // only 2 bytes of comment data
|
||||
]);
|
||||
const track = parseVorbisCommentBlock(data);
|
||||
expect(track.title).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,200 @@
|
||||
import type { Track } from "../../types/metadata.types.js";
|
||||
import { readUint32LE } from "../../utils/buffer-utils.js";
|
||||
|
||||
// FLAC metadata block types
|
||||
const BLOCK_STREAMINFO = 0;
|
||||
const BLOCK_VORBIS_COMMENT = 4;
|
||||
|
||||
/**
|
||||
* Parse FLAC metadata blocks and extract Vorbis comment tags.
|
||||
*
|
||||
* After the 4-byte "fLaC" magic, the file consists of a sequence of
|
||||
* METADATA_BLOCKs. Each block has a 4-byte header:
|
||||
* bit 7: last-metadata-block flag
|
||||
* bits 6-0: block type
|
||||
* bytes 1-3: block length (big-endian, 24-bit)
|
||||
*
|
||||
* Returns a normalised `Track`. Returns an empty object if the buffer
|
||||
* does not start with "fLaC".
|
||||
*/
|
||||
export function parseFlac(buffer: Uint8Array): Track {
|
||||
if (buffer.length < 4) return {};
|
||||
if (
|
||||
buffer[0] !== 0x66 ||
|
||||
buffer[1] !== 0x4c ||
|
||||
buffer[2] !== 0x61 ||
|
||||
buffer[3] !== 0x43
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
let pos = 4;
|
||||
let streamInfoSampleRate = 0;
|
||||
let streamInfoChannels = 0;
|
||||
let streamInfoTotalSamples = 0;
|
||||
|
||||
while (pos + 4 <= buffer.length) {
|
||||
// pos+4 <= buffer.length is guaranteed by the while condition
|
||||
const header = buffer[pos] as number;
|
||||
const isLast = (header & 0x80) !== 0;
|
||||
const blockType = header & 0x7f;
|
||||
const blockLen =
|
||||
((buffer[pos + 1] as number) << 16) |
|
||||
((buffer[pos + 2] as number) << 8) |
|
||||
(buffer[pos + 3] as number);
|
||||
|
||||
pos += 4;
|
||||
|
||||
if (pos + blockLen > buffer.length) break;
|
||||
|
||||
if (blockType === BLOCK_STREAMINFO && blockLen >= 18) {
|
||||
// STREAMINFO: bits 0-15 min block size, 16-31 max block size,
|
||||
// 32-55 min frame size, 56-79 max frame size,
|
||||
// 80-99 sample rate (20 bits), 100-102 channels-1 (3 bits),
|
||||
// 103-107 bits/sample-1 (5 bits), 108-143 total samples (36 bits)
|
||||
const b = buffer.subarray(pos, pos + blockLen);
|
||||
// blockLen >= 18 and subarray(pos, pos+blockLen) is guaranteed in bounds
|
||||
streamInfoSampleRate =
|
||||
((b[10] as number) << 12) |
|
||||
((b[11] as number) << 4) |
|
||||
(((b[12] as number) >> 4) & 0x0f);
|
||||
streamInfoChannels = (((b[12] as number) >> 1) & 0x07) + 1;
|
||||
const totalHigh = (b[13] as number) & 0x0f;
|
||||
const totalLow = readUint32LE(b, 14);
|
||||
streamInfoTotalSamples = totalHigh * 0x100000000 + totalLow;
|
||||
}
|
||||
|
||||
if (blockType === BLOCK_VORBIS_COMMENT) {
|
||||
const track = parseVorbisCommentBlock(
|
||||
buffer.subarray(pos, pos + blockLen)
|
||||
);
|
||||
if (streamInfoSampleRate > 0)
|
||||
track.sampleRate = streamInfoSampleRate;
|
||||
/* istanbul ignore else -- streamInfoChannels formula always produces >= 1 when STREAMINFO present */
|
||||
if (streamInfoChannels > 0) track.channels = streamInfoChannels;
|
||||
if (streamInfoSampleRate > 0 && streamInfoTotalSamples > 0) {
|
||||
track.duration = streamInfoTotalSamples / streamInfoSampleRate;
|
||||
}
|
||||
track.format = "flac";
|
||||
return track;
|
||||
}
|
||||
|
||||
pos += blockLen;
|
||||
if (isLast) break;
|
||||
}
|
||||
|
||||
// No Vorbis comment block found — return minimal track with stream info
|
||||
const track: Track = { format: "flac" };
|
||||
if (streamInfoSampleRate > 0) track.sampleRate = streamInfoSampleRate;
|
||||
/* istanbul ignore else -- streamInfoChannels formula always produces >= 1 when STREAMINFO present */
|
||||
if (streamInfoChannels > 0) track.channels = streamInfoChannels;
|
||||
return track;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared Vorbis comment decoder (used by Ogg parser too)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse a Vorbis comment block (either from FLAC METADATA_BLOCK or Ogg packet).
|
||||
*
|
||||
* Layout:
|
||||
* uint32LE vendor string length
|
||||
* <vendor bytes>
|
||||
* uint32LE comment count
|
||||
* for each comment:
|
||||
* uint32LE length
|
||||
* <"KEY=VALUE" bytes>
|
||||
*/
|
||||
export function parseVorbisCommentBlock(data: Uint8Array): Track {
|
||||
if (data.length < 8) return {};
|
||||
|
||||
let pos = 0;
|
||||
const vendorLen = readUint32LE(data, pos);
|
||||
pos += 4 + vendorLen;
|
||||
if (pos + 4 > data.length) return {};
|
||||
|
||||
const commentCount = readUint32LE(data, pos);
|
||||
pos += 4;
|
||||
|
||||
const raw: Record<string, string | string[]> = {};
|
||||
|
||||
for (let i = 0; i < commentCount; i++) {
|
||||
if (pos + 4 > data.length) break;
|
||||
const commentLen = readUint32LE(data, pos);
|
||||
pos += 4;
|
||||
if (pos + commentLen > data.length) break;
|
||||
const commentStr = new TextDecoder("utf-8").decode(
|
||||
data.subarray(pos, pos + commentLen)
|
||||
);
|
||||
pos += commentLen;
|
||||
|
||||
const eqIdx = commentStr.indexOf("=");
|
||||
if (eqIdx < 0) continue;
|
||||
const key = commentStr.substring(0, eqIdx).toUpperCase();
|
||||
const value = commentStr.substring(eqIdx + 1);
|
||||
const existing = raw[key];
|
||||
if (existing === undefined) {
|
||||
raw[key] = value;
|
||||
} else if (Array.isArray(existing)) {
|
||||
existing.push(value);
|
||||
} else {
|
||||
raw[key] = [existing, value];
|
||||
}
|
||||
}
|
||||
|
||||
return buildTrackFromVorbis(raw);
|
||||
}
|
||||
|
||||
function buildTrackFromVorbis(raw: Record<string, string | string[]>): Track {
|
||||
const get = (key: string): string | undefined => {
|
||||
const v = raw[key];
|
||||
if (!v) return undefined;
|
||||
return Array.isArray(v) ? v[0] : v;
|
||||
};
|
||||
|
||||
const track: Track = { rawTags: raw };
|
||||
|
||||
const title = get("TITLE");
|
||||
const artist = get("ARTIST");
|
||||
const album = get("ALBUM");
|
||||
const albumArtist = get("ALBUMARTIST");
|
||||
const date = get("DATE");
|
||||
const trackNum = get("TRACKNUMBER");
|
||||
const trackTotal = get("TRACKTOTAL") ?? get("TOTALTRACKS");
|
||||
const discNum = get("DISCNUMBER");
|
||||
const discTotal = get("DISCTOTAL") ?? get("TOTALDISCS");
|
||||
const genre = get("GENRE");
|
||||
const comment = get("COMMENT");
|
||||
const lyrics = get("LYRICS") ?? get("UNSYNCEDLYRICS");
|
||||
|
||||
if (title) track.title = title;
|
||||
if (artist) track.artist = artist;
|
||||
if (album) track.album = album;
|
||||
if (albumArtist) track.albumArtist = albumArtist;
|
||||
if (date) {
|
||||
const y = Number.parseInt(date.substring(0, 4), 10);
|
||||
if (!Number.isNaN(y)) track.year = y;
|
||||
}
|
||||
if (trackNum) {
|
||||
const n = Number.parseInt(trackNum, 10);
|
||||
if (!Number.isNaN(n)) track.trackNumber = n;
|
||||
}
|
||||
if (trackTotal) {
|
||||
const n = Number.parseInt(trackTotal, 10);
|
||||
if (!Number.isNaN(n)) track.totalTracks = n;
|
||||
}
|
||||
if (discNum) {
|
||||
const n = Number.parseInt(discNum, 10);
|
||||
if (!Number.isNaN(n)) track.discNumber = n;
|
||||
}
|
||||
if (discTotal) {
|
||||
const n = Number.parseInt(discTotal, 10);
|
||||
if (!Number.isNaN(n)) track.totalDiscs = n;
|
||||
}
|
||||
if (genre) track.genre = genre;
|
||||
if (comment) track.comment = comment;
|
||||
if (lyrics) track.lyrics = lyrics;
|
||||
|
||||
return track;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { parseFlac, parseVorbisCommentBlock } from "./flac-vorbis";
|
||||
@@ -0,0 +1,65 @@
|
||||
# format-detector
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Identifies the audio container/tag format of a raw byte buffer by inspecting its magic bytes. Format detection is the first step in the metadata extraction pipeline — it determines which parser to dispatch to and is therefore on the critical path for every `extractMetadata` call.
|
||||
|
||||
The function never throws; it returns `"unknown"` for any buffer it cannot classify, allowing callers to decide whether to raise an error or silently skip unrecognised files.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects ID3v2 (header magic `ID3`), ID3v1 (tail magic `TAG`), FLAC (`fLaC`), Ogg (`OggS`), WAV (`RIFF`+`WAVE`), AIFF/AIFC (`FORM`+`AIFF`/`AIFC`), and MP4/M4A (`ftyp`/`moov` at offset 4)
|
||||
- Returns `"unknown"` for buffers shorter than 4 bytes or matching no known signature
|
||||
- Detection order is deterministic: formats with overlapping signatures (ID3v2 embedded in MP3 vs. bare ID3v1 trailer) are checked in the right priority order
|
||||
- Zero allocations — only index comparisons against the input buffer
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { detectAudioFormat } from "@suaveplan/audio-metadata";
|
||||
|
||||
const mp3Header = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, ...]);
|
||||
detectAudioFormat(mp3Header); // "id3v2"
|
||||
|
||||
const flacHeader = new Uint8Array([0x66, 0x4c, 0x61, 0x43, ...]);
|
||||
detectAudioFormat(flacHeader); // "flac"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { detectAudioFormat } from "@suaveplan/audio-metadata";
|
||||
import { MetadataError, METADATA_CODES } from "@suaveplan/audio-metadata/errors";
|
||||
|
||||
function requireKnownFormat(buf: Uint8Array) {
|
||||
const fmt = detectAudioFormat(buf);
|
||||
if (fmt === "unknown") {
|
||||
throw new MetadataError(
|
||||
"Cannot detect audio format from buffer magic bytes",
|
||||
METADATA_CODES.UNSUPPORTED_FORMAT,
|
||||
);
|
||||
}
|
||||
return fmt;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `detectAudioFormat(buffer)`
|
||||
|
||||
```ts
|
||||
function detectAudioFormat(buffer: Uint8Array): AudioFormat
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | The raw audio file bytes |
|
||||
|
||||
**Returns:** One of `"id3v2" | "id3v1" | "mp4" | "flac" | "ogg" | "wav" | "aiff" | "unknown"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
MP4/M4A detection checks bytes 4–7 rather than 0–3 because the first four bytes of an MP4 box are its size (variable), not its type. The `ftyp` atom at position 4 is mandatory for valid MPEG-4 files per ISO 14496-12. The `moov` fallback catches bare `moov`-first files that some encoders produce. WAV and AIFF both use RIFF/FORM envelopes; the sub-type discriminator at offset 8 is what distinguishes them, so both checks require at least 12 bytes. ID3v1 is checked last because it is a trailer (last 128 bytes) and a buffer may contain both ID3v2 headers and an ID3v1 trailer — the v2 header takes priority.
|
||||
@@ -0,0 +1,186 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { detectAudioFormat } from "./format-detector.js";
|
||||
|
||||
describe("format-detector", () => {
|
||||
it("detects ID3v2 by header magic", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("id3v2");
|
||||
});
|
||||
|
||||
it("detects FLAC by fLaC magic", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x66, 0x4c, 0x61, 0x43, 0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("flac");
|
||||
});
|
||||
|
||||
it("detects Ogg by OggS magic", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x4f, 0x67, 0x67, 0x53, 0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("ogg");
|
||||
});
|
||||
|
||||
it("detects WAV by RIFF+WAVE magic", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // RIFF
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // size
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45, // WAVE
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("wav");
|
||||
});
|
||||
|
||||
it("does not detect RIFF without WAVE as wav", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x41,
|
||||
0x56,
|
||||
0x49,
|
||||
0x20, // AVI (not WAVE)
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).not.toBe("wav");
|
||||
});
|
||||
|
||||
it("detects AIFF by FORM+AIFF magic", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d, // FORM
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // size
|
||||
0x41,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // AIFF
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("aiff");
|
||||
});
|
||||
|
||||
it("detects AIFC by FORM+AIFC magic", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d, // FORM
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // size
|
||||
0x41,
|
||||
0x49,
|
||||
0x46,
|
||||
0x43, // AIFC
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("aiff");
|
||||
});
|
||||
|
||||
it("detects MP4 by ftyp atom", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1c, // box size
|
||||
0x66,
|
||||
0x74,
|
||||
0x79,
|
||||
0x70, // ftyp
|
||||
0x4d,
|
||||
0x34,
|
||||
0x41,
|
||||
0x20, // M4A brand
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("mp4");
|
||||
});
|
||||
|
||||
it("detects MP4 by moov atom", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08, // box size
|
||||
0x6d,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x76, // moov
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).toBe("mp4");
|
||||
});
|
||||
|
||||
it("detects ID3v1 by TAG trailer", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array(256);
|
||||
buf[128] = 0x54; // T
|
||||
buf[129] = 0x41; // A
|
||||
buf[130] = 0x47; // G
|
||||
expect(detectAudioFormat(buf)).toBe("id3v1");
|
||||
});
|
||||
|
||||
it("does not detect FORM+WAVE as aiff (wrong type)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x46,
|
||||
0x4f,
|
||||
0x52,
|
||||
0x4d, // FORM
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x08, // size
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45, // WAVE (not AIFF/AIFC)
|
||||
]);
|
||||
expect(detectAudioFormat(buf)).not.toBe("aiff");
|
||||
});
|
||||
|
||||
it("returns unknown for 128+ byte buffer without TAG trailer", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Buffer with >= 128 bytes but no TAG magic at position length-128
|
||||
const buf = new Uint8Array(256);
|
||||
// Fill with non-TAG bytes (default is 0x00)
|
||||
expect(detectAudioFormat(buf)).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for empty buffer", { timeout: 5000 }, () => {
|
||||
expect(detectAudioFormat(new Uint8Array([]))).toBe("unknown");
|
||||
});
|
||||
|
||||
it("returns unknown for short buffer", { timeout: 5000 }, () => {
|
||||
expect(detectAudioFormat(new Uint8Array([0x00, 0x01, 0x02]))).toBe(
|
||||
"unknown"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns unknown for random bytes", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
detectAudioFormat(new Uint8Array([0xde, 0xad, 0xbe, 0xef]))
|
||||
).toBe("unknown");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import type { AudioFormat } from "../../types/metadata.types.js";
|
||||
|
||||
/**
|
||||
* Inspect the magic bytes of an audio buffer and return the matching format.
|
||||
*
|
||||
* The detection order matters: ID3v2 is checked before ID3v1 because an MP3
|
||||
* file can carry both header types simultaneously. MP4 detection checks the
|
||||
* `ftyp` atom at offset 4, which is the authoritative indicator for the MPEG-4
|
||||
* container family.
|
||||
*/
|
||||
export function detectAudioFormat(buffer: Uint8Array): AudioFormat {
|
||||
if (buffer.length < 4) return "unknown";
|
||||
|
||||
// ID3v2: header starts with "ID3"
|
||||
if (buffer[0] === 0x49 && buffer[1] === 0x44 && buffer[2] === 0x33) {
|
||||
return "id3v2";
|
||||
}
|
||||
|
||||
// FLAC: starts with "fLaC"
|
||||
if (
|
||||
buffer[0] === 0x66 &&
|
||||
buffer[1] === 0x4c &&
|
||||
buffer[2] === 0x61 &&
|
||||
buffer[3] === 0x43
|
||||
) {
|
||||
return "flac";
|
||||
}
|
||||
|
||||
// Ogg: starts with "OggS"
|
||||
if (
|
||||
buffer[0] === 0x4f &&
|
||||
buffer[1] === 0x67 &&
|
||||
buffer[2] === 0x67 &&
|
||||
buffer[3] === 0x53
|
||||
) {
|
||||
return "ogg";
|
||||
}
|
||||
|
||||
// WAV: "RIFF" at 0 and "WAVE" at 8
|
||||
if (
|
||||
buffer[0] === 0x52 &&
|
||||
buffer[1] === 0x49 &&
|
||||
buffer[2] === 0x46 &&
|
||||
buffer[3] === 0x46 &&
|
||||
buffer.length >= 12 &&
|
||||
buffer[8] === 0x57 &&
|
||||
buffer[9] === 0x41 &&
|
||||
buffer[10] === 0x56 &&
|
||||
buffer[11] === 0x45
|
||||
) {
|
||||
return "wav";
|
||||
}
|
||||
|
||||
// AIFF: "FORM" at 0 and "AIFF"/"AIFC" at 8
|
||||
if (
|
||||
buffer[0] === 0x46 &&
|
||||
buffer[1] === 0x4f &&
|
||||
buffer[2] === 0x52 &&
|
||||
buffer[3] === 0x4d &&
|
||||
buffer.length >= 12
|
||||
) {
|
||||
const form8 = buffer[8];
|
||||
const form9 = buffer[9];
|
||||
const form10 = buffer[10];
|
||||
const form11 = buffer[11];
|
||||
const isAiff =
|
||||
form8 === 0x41 &&
|
||||
form9 === 0x49 &&
|
||||
form10 === 0x46 &&
|
||||
form11 === 0x46;
|
||||
const isAifc =
|
||||
form8 === 0x41 &&
|
||||
form9 === 0x49 &&
|
||||
form10 === 0x46 &&
|
||||
form11 === 0x43;
|
||||
if (isAiff || isAifc) return "aiff";
|
||||
}
|
||||
|
||||
// MP4/M4A: "ftyp" or "moov" at offset 4
|
||||
if (buffer.length >= 8) {
|
||||
const b4 = buffer[4];
|
||||
const b5 = buffer[5];
|
||||
const b6 = buffer[6];
|
||||
const b7 = buffer[7];
|
||||
const isFtyp = b4 === 0x66 && b5 === 0x74 && b6 === 0x79 && b7 === 0x70;
|
||||
const isMoov = b4 === 0x6d && b5 === 0x6f && b6 === 0x6f && b7 === 0x76;
|
||||
if (isFtyp || isMoov) return "mp4";
|
||||
}
|
||||
|
||||
// ID3v1: last 128 bytes start with "TAG"
|
||||
if (buffer.length >= 128) {
|
||||
const tail = buffer.length - 128;
|
||||
if (
|
||||
buffer[tail] === 0x54 &&
|
||||
buffer[tail + 1] === 0x41 &&
|
||||
buffer[tail + 2] === 0x47
|
||||
) {
|
||||
return "id3v1";
|
||||
}
|
||||
}
|
||||
|
||||
return "unknown";
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { detectAudioFormat } from "./format-detector";
|
||||
@@ -0,0 +1,78 @@
|
||||
# id3v1
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses the 128-byte ID3v1 and ID3v1.1 tag that lives at the very end of an MP3 file. ID3v1 is the oldest and most widely supported tag format; while its fixed-length fields and limited character encoding make it inferior to ID3v2, it remains prevalent in older libraries and legacy rips.
|
||||
|
||||
The parser returns a `Track` object with normalised fields and a `rawTags` map so callers can inspect the original values before transformation.
|
||||
|
||||
## Features
|
||||
|
||||
- Detects presence of the "TAG" magic marker at `buffer.length - 128`
|
||||
- Reads title (30 bytes), artist (30 bytes), album (30 bytes), year (4 bytes), comment (28–30 bytes), and genre index (1 byte)
|
||||
- Implements the ID3v1.1 track-number extension: if comment byte 28 is `0x00` and byte 29 is non-zero, byte 29 is the track number
|
||||
- Maps the genre byte to the 192-entry Winamp-extended genre list
|
||||
- Strips trailing null bytes and ASCII whitespace from every field
|
||||
- Returns an empty `Track` when the tag is absent or the buffer is shorter than 128 bytes
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseId3v1 } from "@suaveplan/audio-metadata/parsers/id3v1";
|
||||
|
||||
const buf = new Uint8Array(256);
|
||||
// write "TAG" at offset 128
|
||||
buf[128] = 0x54; buf[129] = 0x41; buf[130] = 0x47;
|
||||
// write title "My Song"
|
||||
const enc = new TextEncoder();
|
||||
enc.encodeInto("My Song", buf.subarray(131));
|
||||
|
||||
const track = parseId3v1(buf);
|
||||
// { title: "My Song", format: "id3v1", ... }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseId3v1 } from "@suaveplan/audio-metadata/parsers/id3v1";
|
||||
|
||||
// Build a minimal ID3v1.1 buffer with track number
|
||||
const buf = new Uint8Array(128);
|
||||
buf[0] = 0x54; buf[1] = 0x41; buf[2] = 0x47; // "TAG"
|
||||
new TextEncoder().encodeInto("Dark Side", buf.subarray(3, 33)); // title
|
||||
new TextEncoder().encodeInto("Pink Floyd", buf.subarray(33, 63)); // artist
|
||||
new TextEncoder().encodeInto("The Wall", buf.subarray(63, 93)); // album
|
||||
new TextEncoder().encodeInto("1979", buf.subarray(93, 97)); // year
|
||||
// comment bytes 28=0, 29=track number
|
||||
buf[125] = 0x00;
|
||||
buf[126] = 3; // track 3
|
||||
buf[127] = 17; // Rock genre
|
||||
|
||||
const track = parseId3v1(buf);
|
||||
console.log(track.artist); // "Pink Floyd"
|
||||
console.log(track.trackNumber); // 3
|
||||
console.log(track.genre); // "Rock"
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseId3v1(buffer)`
|
||||
|
||||
```ts
|
||||
function parseId3v1(buffer: Uint8Array): Track
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | Full audio file buffer (must be ≥ 128 bytes for a tag to be present) |
|
||||
|
||||
**Returns:** A `Track` object. All fields are optional; an empty `Track` (`{}`) is returned when no ID3v1 tag is found.
|
||||
|
||||
**Does not throw.** Out-of-bounds buffers and missing tags produce an empty result.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Latin-1 encoding is used for all field reads because the ID3v1 specification predates UTF-8 adoption in audio tags; using `TextDecoder("utf-8")` would corrupt characters in the 0x80–0xFF range. The ID3v1.1 track-number detection checks byte offsets 28 and 29 of the comment region (absolute offsets 125 and 126 from the tag start), consistent with the de-facto standard introduced by Michael Mutschler in 1997. Null-termination stripping uses a regex to remove all trailing `\0` characters before trimming whitespace, because some encoders pad fields with spaces rather than nulls.
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseId3v1 } from "./id3v1.js";
|
||||
|
||||
/**
|
||||
* Build a minimal ID3v1 tag buffer.
|
||||
* Returns a 128-byte buffer starting at offset 0 (the entire buffer IS the tag).
|
||||
*/
|
||||
function buildId3v1Tag({
|
||||
title = "",
|
||||
artist = "",
|
||||
album = "",
|
||||
year = "",
|
||||
comment = "",
|
||||
track = 0,
|
||||
genreIdx = 255,
|
||||
}: {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
year?: string;
|
||||
comment?: string;
|
||||
track?: number;
|
||||
genreIdx?: number;
|
||||
}): Uint8Array {
|
||||
const buf = new Uint8Array(128);
|
||||
buf[0] = 0x54;
|
||||
buf[1] = 0x41;
|
||||
buf[2] = 0x47; // TAG
|
||||
|
||||
const enc = new TextEncoder();
|
||||
const writeField = (str: string, offset: number, maxLen: number) => {
|
||||
const bytes = enc.encode(str);
|
||||
const len = Math.min(bytes.length, maxLen);
|
||||
buf.set(bytes.subarray(0, len), offset);
|
||||
};
|
||||
|
||||
writeField(title, 3, 30);
|
||||
writeField(artist, 33, 30);
|
||||
writeField(album, 63, 30);
|
||||
writeField(year, 93, 4);
|
||||
|
||||
if (track > 0) {
|
||||
writeField(comment, 97, 28);
|
||||
buf[125] = 0x00;
|
||||
buf[126] = track;
|
||||
} else {
|
||||
writeField(comment, 97, 30);
|
||||
}
|
||||
|
||||
buf[127] = genreIdx;
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("id3v1 parser", () => {
|
||||
it("parses title, artist, album, year", { timeout: 5000 }, () => {
|
||||
const buf = buildId3v1Tag({
|
||||
title: "My Song",
|
||||
artist: "My Artist",
|
||||
album: "My Album",
|
||||
year: "2023",
|
||||
});
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.title).toBe("My Song");
|
||||
expect(track.artist).toBe("My Artist");
|
||||
expect(track.album).toBe("My Album");
|
||||
expect(track.year).toBe(2023);
|
||||
expect(track.format).toBe("id3v1");
|
||||
});
|
||||
|
||||
it("parses genre by index", { timeout: 5000 }, () => {
|
||||
const buf = buildId3v1Tag({ genreIdx: 17 });
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.genre).toBe("Rock");
|
||||
});
|
||||
|
||||
it("parses track number (ID3v1.1)", { timeout: 5000 }, () => {
|
||||
const buf = buildId3v1Tag({ comment: "Nice track", track: 5 });
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.trackNumber).toBe(5);
|
||||
expect(track.comment).toBe("Nice track");
|
||||
});
|
||||
|
||||
it("parses comment without track number", { timeout: 5000 }, () => {
|
||||
const buf = buildId3v1Tag({ comment: "Full comment here" });
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.comment).toBe("Full comment here");
|
||||
expect(track.trackNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it("strips trailing nulls and whitespace", { timeout: 5000 }, () => {
|
||||
const buf = buildId3v1Tag({ title: "Song " });
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.title).toBe("Song");
|
||||
});
|
||||
|
||||
it("returns empty track when buffer too short", { timeout: 5000 }, () => {
|
||||
const track = parseId3v1(new Uint8Array(64));
|
||||
expect(track).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty track when TAG magic missing", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array(128);
|
||||
// No TAG magic
|
||||
const track = parseId3v1(buf);
|
||||
expect(track).toEqual({});
|
||||
});
|
||||
|
||||
it("handles unknown genre index gracefully", { timeout: 5000 }, () => {
|
||||
const buf = buildId3v1Tag({ genreIdx: 200 }); // Beyond known list
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.genre).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns rawTags with parsed fields", { timeout: 5000 }, () => {
|
||||
const buf = buildId3v1Tag({ title: "Test", artist: "Artist" });
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.rawTags?.["TIT2"]).toBe("Test");
|
||||
expect(track.rawTags?.["TPE1"]).toBe("Artist");
|
||||
});
|
||||
|
||||
it("ignores non-numeric year string", { timeout: 5000 }, () => {
|
||||
// year = "abcd" → parseInt returns NaN → year should not be set
|
||||
const buf = buildId3v1Tag({ title: "Song", year: "abcd" });
|
||||
const track = parseId3v1(buf);
|
||||
expect(track.year).toBeUndefined();
|
||||
});
|
||||
});
|
||||
231
packages/media/audio/audio-metadata/src/parsers/id3v1/id3v1.ts
Normal file
231
packages/media/audio/audio-metadata/src/parsers/id3v1/id3v1.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import type { Track } from "../../types/metadata.types.js";
|
||||
import { readString, readUint8 } from "../../utils/buffer-utils.js";
|
||||
|
||||
/**
|
||||
* ID3v1 genre list (index → name).
|
||||
* Entries follow the Winamp extended list (0–191).
|
||||
*/
|
||||
const ID3V1_GENRES: readonly string[] = [
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
"Alternative",
|
||||
"Ska",
|
||||
"Death Metal",
|
||||
"Pranks",
|
||||
"Soundtrack",
|
||||
"Euro-Techno",
|
||||
"Ambient",
|
||||
"Trip-Hop",
|
||||
"Vocal",
|
||||
"Jazz+Funk",
|
||||
"Fusion",
|
||||
"Trance",
|
||||
"Classical",
|
||||
"Instrumental",
|
||||
"Acid",
|
||||
"House",
|
||||
"Game",
|
||||
"Sound Clip",
|
||||
"Gospel",
|
||||
"Noise",
|
||||
"AlternRock",
|
||||
"Bass",
|
||||
"Soul",
|
||||
"Punk",
|
||||
"Space",
|
||||
"Meditative",
|
||||
"Instrumental Pop",
|
||||
"Instrumental Rock",
|
||||
"Ethnic",
|
||||
"Gothic",
|
||||
"Darkwave",
|
||||
"Techno-Industrial",
|
||||
"Electronic",
|
||||
"Pop-Folk",
|
||||
"Eurodance",
|
||||
"Dream",
|
||||
"Southern Rock",
|
||||
"Comedy",
|
||||
"Cult",
|
||||
"Gangsta",
|
||||
"Top 40",
|
||||
"Christian Rap",
|
||||
"Pop/Funk",
|
||||
"Jungle",
|
||||
"Native American",
|
||||
"Cabaret",
|
||||
"New Wave",
|
||||
"Psychadelic",
|
||||
"Rave",
|
||||
"Showtunes",
|
||||
"Trailer",
|
||||
"Lo-Fi",
|
||||
"Tribal",
|
||||
"Acid Punk",
|
||||
"Acid Jazz",
|
||||
"Polka",
|
||||
"Retro",
|
||||
"Musical",
|
||||
"Rock & Roll",
|
||||
"Hard Rock",
|
||||
"Folk",
|
||||
"Folk-Rock",
|
||||
"National Folk",
|
||||
"Swing",
|
||||
"Fast Fusion",
|
||||
"Bebob",
|
||||
"Latin",
|
||||
"Revival",
|
||||
"Celtic",
|
||||
"Bluegrass",
|
||||
"Avantgarde",
|
||||
"Gothic Rock",
|
||||
"Progressive Rock",
|
||||
"Psychedelic Rock",
|
||||
"Symphonic Rock",
|
||||
"Slow Rock",
|
||||
"Big Band",
|
||||
"Chorus",
|
||||
"Easy Listening",
|
||||
"Acoustic",
|
||||
"Humour",
|
||||
"Speech",
|
||||
"Chanson",
|
||||
"Opera",
|
||||
"Chamber Music",
|
||||
"Sonata",
|
||||
"Symphony",
|
||||
"Booty Bass",
|
||||
"Primus",
|
||||
"Porn Groove",
|
||||
"Satire",
|
||||
"Slow Jam",
|
||||
"Club",
|
||||
"Tango",
|
||||
"Samba",
|
||||
"Folklore",
|
||||
"Ballad",
|
||||
"Power Ballad",
|
||||
"Rhythmic Soul",
|
||||
"Freestyle",
|
||||
"Duet",
|
||||
"Punk Rock",
|
||||
"Drum Solo",
|
||||
"A capella",
|
||||
"Euro-House",
|
||||
"Dance Hall",
|
||||
"Goa",
|
||||
"Drum & Bass",
|
||||
"Club-House",
|
||||
"Hardcore",
|
||||
"Terror",
|
||||
"Indie",
|
||||
"BritPop",
|
||||
"Negerpunk",
|
||||
"Polsk Punk",
|
||||
"Beat",
|
||||
"Christian Gangsta Rap",
|
||||
"Heavy Metal",
|
||||
"Black Metal",
|
||||
"Crossover",
|
||||
"Contemporary Christian",
|
||||
"Christian Rock",
|
||||
"Merengue",
|
||||
"Salsa",
|
||||
"Trash Metal",
|
||||
"Anime",
|
||||
"JPop",
|
||||
"Synthpop",
|
||||
];
|
||||
|
||||
/**
|
||||
* Strip trailing null bytes and whitespace from a field string.
|
||||
*/
|
||||
function trimField(s: string): string {
|
||||
return s.replace(/\0+$/, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the 128-byte ID3v1 tag located at the very end of an audio buffer.
|
||||
*
|
||||
* Returns an empty `Track` if the tail is not present or the magic "TAG" is
|
||||
* missing — callers should fall back to other parsers in that case.
|
||||
*/
|
||||
export function parseId3v1(buffer: Uint8Array): Track {
|
||||
if (buffer.length < 128) return {};
|
||||
|
||||
const offset = buffer.length - 128;
|
||||
|
||||
// Magic: "TAG"
|
||||
if (
|
||||
buffer[offset] !== 0x54 ||
|
||||
buffer[offset + 1] !== 0x41 ||
|
||||
buffer[offset + 2] !== 0x47
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const raw: Record<string, string> = {};
|
||||
|
||||
const title = trimField(readString(buffer, offset + 3, 30, "latin1"));
|
||||
const artist = trimField(readString(buffer, offset + 33, 30, "latin1"));
|
||||
const album = trimField(readString(buffer, offset + 63, 30, "latin1"));
|
||||
const year = trimField(readString(buffer, offset + 93, 4, "latin1"));
|
||||
const commentRaw = readString(buffer, offset + 97, 30, "latin1");
|
||||
const genreIdx = readUint8(buffer, offset + 127);
|
||||
|
||||
// ID3v1.1: if byte 28 of comment is 0x00 and byte 29 is non-zero, it's a track number
|
||||
const commentByte28 = readUint8(buffer, offset + 125);
|
||||
const commentByte29 = readUint8(buffer, offset + 126);
|
||||
const hasTrack = commentByte28 === 0x00 && commentByte29 !== 0x00;
|
||||
|
||||
const commentStr = trimField(
|
||||
hasTrack ? commentRaw.slice(0, 28) : commentRaw
|
||||
);
|
||||
const trackNum = hasTrack ? commentByte29 : undefined;
|
||||
|
||||
if (title) raw["TIT2"] = title;
|
||||
if (artist) raw["TPE1"] = artist;
|
||||
if (album) raw["TALB"] = album;
|
||||
if (year) raw["TDRC"] = year;
|
||||
if (commentStr) raw["COMM"] = commentStr;
|
||||
|
||||
const track: Track = {
|
||||
format: "id3v1",
|
||||
rawTags: raw,
|
||||
};
|
||||
|
||||
if (title) track.title = title;
|
||||
if (artist) track.artist = artist;
|
||||
if (album) track.album = album;
|
||||
if (year) {
|
||||
const y = Number.parseInt(year, 10);
|
||||
if (!Number.isNaN(y)) track.year = y;
|
||||
}
|
||||
if (commentStr) track.comment = commentStr;
|
||||
if (trackNum !== undefined) track.trackNumber = trackNum;
|
||||
if (genreIdx < ID3V1_GENRES.length) {
|
||||
const genreName = ID3V1_GENRES[genreIdx] as string;
|
||||
track.genre = genreName;
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { parseId3v1 } from "./id3v1";
|
||||
103
packages/media/audio/audio-metadata/src/parsers/id3v2/id3v2.md
Normal file
103
packages/media/audio/audio-metadata/src/parsers/id3v2/id3v2.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# id3v2
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses ID3v2.2, ID3v2.3, and ID3v2.4 tags embedded at the start of an audio buffer. ID3v2 is the dominant tag format for MP3 files and is also found in AIFF containers. The parser handles the full tag structure — header, optional extended header, and variable-length frames — and normalises the result into a `Track` record with optional raw-tag passthrough.
|
||||
|
||||
The parser is intentionally defensive: it stops processing frames when it encounters padding or an out-of-bounds condition, rather than throwing.
|
||||
|
||||
## Features
|
||||
|
||||
- Supports ID3v2.2 (3-char frame IDs, 3-byte sizes), ID3v2.3 (4-char IDs, 32-bit sizes), and ID3v2.4 (4-char IDs, syncsafe sizes)
|
||||
- Whole-tag unsynchronisation for v2.2/v2.3 (v2.4 per-frame unsync is not applied — rare in practice)
|
||||
- Extended header skipping for v2.3 and v2.4
|
||||
- Text encoding detection: ISO-8859-1 (0x00), UTF-16 with BOM (0x01), UTF-16 BE (0x02), UTF-8 (0x03)
|
||||
- Numeric genre reference resolution: `(17)` → `Rock`, `17` → `Rock`, `(17)Rock` → `Rock`
|
||||
- TRCK and TPOS parsing with total-count split: `"3/12"` → `trackNumber=3`, `totalTracks=12`
|
||||
- APIC cover extraction with MIME type, picture type, and description parsing
|
||||
- COMM and USLT frame decoding with language/description prefix stripping
|
||||
- Unknown text frames stored in `rawTags`
|
||||
- Options: `skipCovers`, `skipLyrics`, `maxCoverSize`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseId3v2 } from "@suaveplan/audio-metadata/parsers/id3v2";
|
||||
|
||||
// Build a minimal ID3v2.3 tag with a title frame
|
||||
const title = "Hello World";
|
||||
const enc = new TextEncoder();
|
||||
const titleBytes = enc.encode(title);
|
||||
// TIT2 frame: encoding(0) + title bytes
|
||||
const frameData = new Uint8Array([0, ...titleBytes]);
|
||||
const frameSize = frameData.length;
|
||||
// frame header: "TIT2" + size (uint32BE) + flags
|
||||
const frame = new Uint8Array([
|
||||
0x54, 0x49, 0x54, 0x32,
|
||||
0, 0, 0, frameSize,
|
||||
0, 0,
|
||||
...frameData,
|
||||
]);
|
||||
// ID3v2.3 header: "ID3" + version(3,0) + flags(0) + syncsafe size
|
||||
const tagSize = frame.length;
|
||||
const ss = new Uint8Array([
|
||||
(tagSize >> 21) & 0x7f,
|
||||
(tagSize >> 14) & 0x7f,
|
||||
(tagSize >> 7) & 0x7f,
|
||||
tagSize & 0x7f,
|
||||
]);
|
||||
const header = new Uint8Array([0x49, 0x44, 0x33, 3, 0, 0, ...ss]);
|
||||
const buf = new Uint8Array([...header, ...frame]);
|
||||
|
||||
const track = parseId3v2(buf);
|
||||
// { title: "Hello World", format: "id3v2", rawTags: { TIT2: "Hello World" } }
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseId3v2 } from "@suaveplan/audio-metadata/parsers/id3v2";
|
||||
|
||||
const track = parseId3v2(audioBuffer, {
|
||||
skipCovers: false,
|
||||
skipLyrics: true,
|
||||
maxCoverSize: 2 * 1024 * 1024, // 2 MiB
|
||||
});
|
||||
|
||||
if (track.cover) {
|
||||
const img = new Blob([track.cover.data], { type: track.cover.mimeType });
|
||||
const url = URL.createObjectURL(img);
|
||||
document.querySelector("img")!.src = url;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseId3v2(buffer, options?)`
|
||||
|
||||
```ts
|
||||
function parseId3v2(
|
||||
buffer: Uint8Array,
|
||||
options?: {
|
||||
skipCovers?: boolean;
|
||||
skipLyrics?: boolean;
|
||||
maxCoverSize?: number;
|
||||
},
|
||||
): Track
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | Buffer beginning with the ID3v2 header |
|
||||
| `options.skipCovers` | `boolean` | When `true`, APIC frames are skipped entirely |
|
||||
| `options.skipLyrics` | `boolean` | When `true`, USLT frames are skipped |
|
||||
| `options.maxCoverSize` | `number` | Cover images larger than this byte count are discarded (default 5 MiB) |
|
||||
|
||||
**Returns:** A `Track` object. Returns `{}` if the buffer does not start with ID3 magic bytes.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Syncsafe integers use 7 bits per byte, giving a maximum tag size of 256 MiB. The parser does not enforce an upper size limit on the tag itself — callers should validate input size before parsing. UTF-16 BOM detection relies on `TextDecoder("utf-16")` which reads the BOM automatically; without a BOM the decoder defaults to little-endian, matching the ID3 spec. Genre resolution handles the common Winamp `(N)` notation because many encoders still write this format even for ID3v2.4 files. The `readUint16BE` re-export is provided for test convenience only and is not part of the public parser API.
|
||||
1340
packages/media/audio/audio-metadata/src/parsers/id3v2/id3v2.test.ts
Normal file
1340
packages/media/audio/audio-metadata/src/parsers/id3v2/id3v2.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
536
packages/media/audio/audio-metadata/src/parsers/id3v2/id3v2.ts
Normal file
536
packages/media/audio/audio-metadata/src/parsers/id3v2/id3v2.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
import type { CoverArt, Track } from "../../types/metadata.types.js";
|
||||
import {
|
||||
readString,
|
||||
readSyncsafeInt,
|
||||
readUint8,
|
||||
readUint16BE,
|
||||
readUint32BE,
|
||||
} from "../../utils/buffer-utils.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decode text from an ID3v2 text frame.
|
||||
*
|
||||
* Byte 0 of the frame data is the encoding flag:
|
||||
* 0x00 = ISO-8859-1 (latin1)
|
||||
* 0x01 = UTF-16 with BOM
|
||||
* 0x02 = UTF-16 BE without BOM
|
||||
* 0x03 = UTF-8
|
||||
*/
|
||||
function decodeId3Text(data: Uint8Array): string {
|
||||
// data.length >= 1 is guaranteed: frameSize===0 causes break before decodeId3Text is called
|
||||
const encoding = data[0] as number;
|
||||
const content = data.subarray(1);
|
||||
|
||||
if (encoding === 0x01) {
|
||||
// UTF-16 with BOM — remove BOM if present
|
||||
const decoder = new TextDecoder("utf-16");
|
||||
return decoder.decode(content).replace(/\0/g, "");
|
||||
}
|
||||
if (encoding === 0x02) {
|
||||
const decoder = new TextDecoder("utf-16be");
|
||||
return decoder.decode(content).replace(/\0/g, "");
|
||||
}
|
||||
if (encoding === 0x03) {
|
||||
const decoder = new TextDecoder("utf-8");
|
||||
return decoder.decode(content).replace(/\0/g, "");
|
||||
}
|
||||
// Default: latin1 (encoding === 0x00)
|
||||
let out = "";
|
||||
for (let i = 0; i < content.length; i++) {
|
||||
const b = content[i] as number;
|
||||
if (b === 0) break;
|
||||
out += String.fromCharCode(b);
|
||||
}
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip leading/trailing whitespace and null chars from a genre string and
|
||||
* resolve numeric genre references like "(17)" to their Winamp genre names.
|
||||
*/
|
||||
const GENRES: readonly string[] = [
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
"Alternative",
|
||||
"Ska",
|
||||
"Death Metal",
|
||||
"Pranks",
|
||||
"Soundtrack",
|
||||
"Euro-Techno",
|
||||
"Ambient",
|
||||
"Trip-Hop",
|
||||
"Vocal",
|
||||
"Jazz+Funk",
|
||||
"Fusion",
|
||||
"Trance",
|
||||
"Classical",
|
||||
"Instrumental",
|
||||
"Acid",
|
||||
"House",
|
||||
"Game",
|
||||
"Sound Clip",
|
||||
"Gospel",
|
||||
"Noise",
|
||||
"AlternRock",
|
||||
"Bass",
|
||||
"Soul",
|
||||
"Punk",
|
||||
"Space",
|
||||
"Meditative",
|
||||
"Instrumental Pop",
|
||||
"Instrumental Rock",
|
||||
"Ethnic",
|
||||
"Gothic",
|
||||
"Darkwave",
|
||||
"Techno-Industrial",
|
||||
"Electronic",
|
||||
"Pop-Folk",
|
||||
"Eurodance",
|
||||
"Dream",
|
||||
"Southern Rock",
|
||||
"Comedy",
|
||||
"Cult",
|
||||
"Gangsta",
|
||||
"Top 40",
|
||||
"Christian Rap",
|
||||
"Pop/Funk",
|
||||
"Jungle",
|
||||
"Native American",
|
||||
"Cabaret",
|
||||
"New Wave",
|
||||
"Psychadelic",
|
||||
"Rave",
|
||||
"Showtunes",
|
||||
"Trailer",
|
||||
"Lo-Fi",
|
||||
"Tribal",
|
||||
"Acid Punk",
|
||||
"Acid Jazz",
|
||||
"Polka",
|
||||
"Retro",
|
||||
"Musical",
|
||||
"Rock & Roll",
|
||||
"Hard Rock",
|
||||
"Folk",
|
||||
"Folk-Rock",
|
||||
"National Folk",
|
||||
"Swing",
|
||||
"Fast Fusion",
|
||||
"Bebob",
|
||||
"Latin",
|
||||
"Revival",
|
||||
"Celtic",
|
||||
"Bluegrass",
|
||||
"Avantgarde",
|
||||
"Gothic Rock",
|
||||
"Progressive Rock",
|
||||
"Psychedelic Rock",
|
||||
"Symphonic Rock",
|
||||
"Slow Rock",
|
||||
"Big Band",
|
||||
"Chorus",
|
||||
"Easy Listening",
|
||||
"Acoustic",
|
||||
"Humour",
|
||||
"Speech",
|
||||
"Chanson",
|
||||
"Opera",
|
||||
"Chamber Music",
|
||||
"Sonata",
|
||||
"Symphony",
|
||||
"Booty Bass",
|
||||
"Primus",
|
||||
"Porn Groove",
|
||||
"Satire",
|
||||
"Slow Jam",
|
||||
"Club",
|
||||
"Tango",
|
||||
"Samba",
|
||||
"Folklore",
|
||||
"Ballad",
|
||||
"Power Ballad",
|
||||
"Rhythmic Soul",
|
||||
"Freestyle",
|
||||
"Duet",
|
||||
"Punk Rock",
|
||||
"Drum Solo",
|
||||
"A capella",
|
||||
"Euro-House",
|
||||
"Dance Hall",
|
||||
"Goa",
|
||||
"Drum & Bass",
|
||||
"Club-House",
|
||||
"Hardcore",
|
||||
"Terror",
|
||||
"Indie",
|
||||
"BritPop",
|
||||
"Negerpunk",
|
||||
"Polsk Punk",
|
||||
"Beat",
|
||||
"Christian Gangsta Rap",
|
||||
"Heavy Metal",
|
||||
"Black Metal",
|
||||
"Crossover",
|
||||
"Contemporary Christian",
|
||||
"Christian Rock",
|
||||
"Merengue",
|
||||
"Salsa",
|
||||
"Trash Metal",
|
||||
"Anime",
|
||||
"JPop",
|
||||
"Synthpop",
|
||||
];
|
||||
|
||||
function resolveGenre(raw: string): string {
|
||||
const cleaned = raw.trim().replace(/\0/g, "");
|
||||
// Numeric reference: "(17)" or just "17"
|
||||
const numMatch = cleaned.match(/^\(?(\d+)\)?$/);
|
||||
if (numMatch) {
|
||||
// numMatch[1] is always defined: regex \d+ guarantees capture group 1
|
||||
const idx = Number.parseInt(numMatch[1] as string, 10);
|
||||
// GENRES[idx] may be undefined if idx >= GENRES.length; fall back to cleaned
|
||||
return GENRES[idx] ?? cleaned;
|
||||
}
|
||||
// Mixed: "(17)Rock" style
|
||||
const mixedMatch = cleaned.match(/^\((\d+)\)(.+)/);
|
||||
if (mixedMatch) {
|
||||
// mixedMatch[1] and [2] are always defined by the regex capture groups
|
||||
const idx = Number.parseInt(mixedMatch[1] as string, 10);
|
||||
const suffix = (mixedMatch[2] as string).trim();
|
||||
if (suffix && suffix !== "CR" && suffix !== "RX") return suffix;
|
||||
// GENRES[idx] may be undefined if idx >= GENRES.length; fall back to cleaned
|
||||
return GENRES[idx] ?? cleaned;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main parser
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse an ID3v2.2, v2.3, or v2.4 tag from the start of a buffer.
|
||||
*
|
||||
* Returns a normalised `Track`. Returns an empty object if the buffer does
|
||||
* not start with the ID3 magic bytes.
|
||||
*/
|
||||
export function parseId3v2(
|
||||
buffer: Uint8Array,
|
||||
options?: {
|
||||
skipCovers?: boolean;
|
||||
skipLyrics?: boolean;
|
||||
maxCoverSize?: number;
|
||||
}
|
||||
): Track {
|
||||
if (buffer.length < 10) return {};
|
||||
if (buffer[0] !== 0x49 || buffer[1] !== 0x44 || buffer[2] !== 0x33) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const majorVersion = readUint8(buffer, 3);
|
||||
// const revision = readUint8(buffer, 4);
|
||||
const flags = readUint8(buffer, 5);
|
||||
const unsync = (flags & 0x80) !== 0;
|
||||
|
||||
let tagSize = readSyncsafeInt(buffer, 6);
|
||||
// Account for the 10-byte header
|
||||
const tagEnd = 10 + tagSize;
|
||||
|
||||
// Extended header (v2.3+): skip it
|
||||
let pos = 10;
|
||||
if ((flags & 0x40) !== 0 && majorVersion >= 3) {
|
||||
const extSize =
|
||||
majorVersion === 4
|
||||
? readSyncsafeInt(buffer, pos)
|
||||
: readUint32BE(buffer, pos);
|
||||
pos += extSize;
|
||||
}
|
||||
|
||||
// Unsynchronisation: rebuild buffer if flag set (v2.3 whole-tag unsync)
|
||||
let frameBuf = buffer;
|
||||
if (unsync && majorVersion < 4) {
|
||||
const synced: number[] = [];
|
||||
for (let i = pos; i < Math.min(tagEnd, buffer.length); i++) {
|
||||
// i < buffer.length is guaranteed by the loop condition
|
||||
synced.push(buffer[i] as number);
|
||||
if (buffer[i] === 0xff && buffer[i + 1] === 0x00) i++; // skip stuffed byte
|
||||
}
|
||||
frameBuf = new Uint8Array(synced);
|
||||
tagSize = frameBuf.length;
|
||||
pos = 0;
|
||||
}
|
||||
|
||||
const raw: Record<string, string | string[]> = {};
|
||||
const track: Track = { format: "id3v2" };
|
||||
const maxCoverSize = options?.maxCoverSize ?? 5 * 1024 * 1024;
|
||||
|
||||
while (
|
||||
pos + (majorVersion === 2 ? 6 : 10) <=
|
||||
Math.min(tagEnd, frameBuf.length)
|
||||
) {
|
||||
// v2.2 uses 3-char frame IDs and 3-byte sizes
|
||||
let frameId: string;
|
||||
let frameSize: number;
|
||||
let headerLen: number;
|
||||
|
||||
if (majorVersion === 2) {
|
||||
frameId = readString(frameBuf, pos, 3, "latin1");
|
||||
frameSize =
|
||||
((frameBuf[pos + 3] as number) << 16) |
|
||||
((frameBuf[pos + 4] as number) << 8) |
|
||||
(frameBuf[pos + 5] as number);
|
||||
headerLen = 6;
|
||||
} else {
|
||||
frameId = readString(frameBuf, pos, 4, "latin1");
|
||||
frameSize =
|
||||
majorVersion === 4
|
||||
? readSyncsafeInt(frameBuf, pos + 4)
|
||||
: readUint32BE(frameBuf, pos + 4);
|
||||
headerLen = 10;
|
||||
}
|
||||
|
||||
if (frameId === "\0\0\0\0" || frameId === "\0\0\0" || frameSize === 0)
|
||||
break;
|
||||
if (pos + headerLen + frameSize > frameBuf.length) break;
|
||||
|
||||
const frameData = frameBuf.subarray(
|
||||
pos + headerLen,
|
||||
pos + headerLen + frameSize
|
||||
);
|
||||
pos += headerLen + frameSize;
|
||||
|
||||
// Normalise v2.2 frame IDs to v2.3/v2.4 equivalents
|
||||
const id = normaliseFrameId(frameId, majorVersion);
|
||||
|
||||
switch (id) {
|
||||
case "TIT2": {
|
||||
const val = decodeId3Text(frameData);
|
||||
if (val) {
|
||||
track.title = val;
|
||||
raw["TIT2"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TPE1": {
|
||||
const val = decodeId3Text(frameData);
|
||||
if (val) {
|
||||
track.artist = val;
|
||||
raw["TPE1"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TALB": {
|
||||
const val = decodeId3Text(frameData);
|
||||
if (val) {
|
||||
track.album = val;
|
||||
raw["TALB"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TPE2": {
|
||||
const val = decodeId3Text(frameData);
|
||||
if (val) {
|
||||
track.albumArtist = val;
|
||||
raw["TPE2"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TDRC":
|
||||
case "TYER": {
|
||||
const val = decodeId3Text(frameData);
|
||||
if (val) {
|
||||
raw[id] = val;
|
||||
const y = Number.parseInt(val.substring(0, 4), 10);
|
||||
if (!Number.isNaN(y)) track.year = y;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TRCK": {
|
||||
const val = decodeId3Text(frameData);
|
||||
if (val) {
|
||||
raw["TRCK"] = val;
|
||||
const parts = val.split("/");
|
||||
// split() always produces at least one element; parts[0] is defined
|
||||
const num = Number.parseInt(parts[0] as string, 10);
|
||||
const total = Number.parseInt(parts[1] ?? "", 10);
|
||||
if (!Number.isNaN(num)) track.trackNumber = num;
|
||||
if (!Number.isNaN(total)) track.totalTracks = total;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TPOS": {
|
||||
const val = decodeId3Text(frameData);
|
||||
if (val) {
|
||||
raw["TPOS"] = val;
|
||||
const parts = val.split("/");
|
||||
// split() always produces at least one element; parts[0] is defined
|
||||
const num = Number.parseInt(parts[0] as string, 10);
|
||||
const total = Number.parseInt(parts[1] ?? "", 10);
|
||||
if (!Number.isNaN(num)) track.discNumber = num;
|
||||
if (!Number.isNaN(total)) track.totalDiscs = total;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "TCON": {
|
||||
const val = resolveGenre(decodeId3Text(frameData));
|
||||
if (val) {
|
||||
track.genre = val;
|
||||
raw["TCON"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "COMM": {
|
||||
// encoding(1) + language(3) + short description (null-term) + text
|
||||
if (frameData.length < 5) break;
|
||||
const langAndDesc = frameData.subarray(4);
|
||||
// Find null terminator for description (encoding-aware; simplified: first null)
|
||||
let nullPos = 0;
|
||||
for (let i = 0; i < langAndDesc.length; i++) {
|
||||
if (langAndDesc[i] === 0x00) {
|
||||
nullPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const textPart = langAndDesc.subarray(nullPos + 1);
|
||||
// frameData.length >= 5 is guaranteed by the check above
|
||||
const encoding = frameData[0] as number;
|
||||
const textData = new Uint8Array(textPart.length + 1);
|
||||
textData[0] = encoding;
|
||||
textData.set(textPart, 1);
|
||||
const val = decodeId3Text(textData);
|
||||
if (val) {
|
||||
track.comment = val;
|
||||
raw["COMM"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "USLT": {
|
||||
if (options?.skipLyrics) break;
|
||||
if (frameData.length < 5) break;
|
||||
const langAndDesc = frameData.subarray(4);
|
||||
let nullPos = 0;
|
||||
for (let i = 0; i < langAndDesc.length; i++) {
|
||||
if (langAndDesc[i] === 0x00) {
|
||||
nullPos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const textPart = langAndDesc.subarray(nullPos + 1);
|
||||
// frameData.length >= 5 is guaranteed by the check above
|
||||
const encoding = frameData[0] as number;
|
||||
const textData = new Uint8Array(textPart.length + 1);
|
||||
textData[0] = encoding;
|
||||
textData.set(textPart, 1);
|
||||
const val = decodeId3Text(textData);
|
||||
if (val) {
|
||||
track.lyrics = val;
|
||||
raw["USLT"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "APIC": {
|
||||
if (options?.skipCovers) break;
|
||||
const cover = parseApic(frameData, maxCoverSize);
|
||||
if (cover) track.cover = cover;
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Store unknown text frames in rawTags
|
||||
if (id.startsWith("T") && frameData.length > 0) {
|
||||
raw[id] = decodeId3Text(frameData);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
track.rawTags = raw;
|
||||
return track;
|
||||
}
|
||||
|
||||
/** Map v2.2 three-character IDs to their v2.3/v2.4 equivalents. */
|
||||
function normaliseFrameId(id: string, version: number): string {
|
||||
if (version !== 2) return id;
|
||||
const map: Record<string, string> = {
|
||||
TT2: "TIT2",
|
||||
TP1: "TPE1",
|
||||
TAL: "TALB",
|
||||
TP2: "TPE2",
|
||||
TYE: "TYER",
|
||||
TRK: "TRCK",
|
||||
TPA: "TPOS",
|
||||
TCO: "TCON",
|
||||
COM: "COMM",
|
||||
ULT: "USLT",
|
||||
PIC: "APIC",
|
||||
};
|
||||
return map[id] ?? id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse an APIC (attached picture) frame.
|
||||
*
|
||||
* Layout: encoding(1) + mime(null-term) + picture type(1) + description(null-term) + data
|
||||
*/
|
||||
function parseApic(data: Uint8Array, maxSize: number): CoverArt | null {
|
||||
if (data.length < 4) return null;
|
||||
|
||||
// Find null terminator after MIME type
|
||||
let mimeEnd = 1;
|
||||
while (mimeEnd < data.length && data[mimeEnd] !== 0x00) mimeEnd++;
|
||||
const mimeType = readString(data, 1, mimeEnd - 1, "latin1") || "image/jpeg";
|
||||
|
||||
if (mimeEnd + 2 >= data.length) return null;
|
||||
// const pictureType = data[mimeEnd + 1]; // 0x03 = front cover
|
||||
const afterPicType = mimeEnd + 2;
|
||||
|
||||
// Find end of description (null byte, possibly double-null for UTF-16)
|
||||
let descEnd = afterPicType;
|
||||
// data.length >= 4 here (checked above); data[0] is always defined
|
||||
const encoding = data[0] as number;
|
||||
if (encoding === 0x01 || encoding === 0x02) {
|
||||
// UTF-16: look for double null
|
||||
while (descEnd + 1 < data.length) {
|
||||
if (data[descEnd] === 0x00 && data[descEnd + 1] === 0x00) {
|
||||
descEnd += 2;
|
||||
break;
|
||||
}
|
||||
descEnd += 2;
|
||||
}
|
||||
} else {
|
||||
while (descEnd < data.length && data[descEnd] !== 0x00) descEnd++;
|
||||
descEnd++;
|
||||
}
|
||||
|
||||
const imgData = data.subarray(descEnd);
|
||||
if (imgData.length === 0 || imgData.length > maxSize) return null;
|
||||
|
||||
return {
|
||||
data: imgData,
|
||||
mimeType,
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export helper for tests
|
||||
export { readUint16BE };
|
||||
@@ -0,0 +1 @@
|
||||
export { parseId3v2, readUint16BE } from "./id3v2";
|
||||
8
packages/media/audio/audio-metadata/src/parsers/index.ts
Normal file
8
packages/media/audio/audio-metadata/src/parsers/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export { parseAiff } from "./aiff-id3";
|
||||
export { parseFlac, parseVorbisCommentBlock } from "./flac-vorbis";
|
||||
export { detectAudioFormat } from "./format-detector";
|
||||
export { parseId3v1 } from "./id3v1";
|
||||
export { parseId3v2, readUint16BE } from "./id3v2";
|
||||
export { parseMp4 } from "./mp4-atom";
|
||||
export { parseOgg } from "./ogg-vorbis";
|
||||
export { parseWav } from "./wav-riff";
|
||||
@@ -0,0 +1 @@
|
||||
export { parseMp4 } from "./mp4-atom";
|
||||
@@ -0,0 +1,72 @@
|
||||
# mp4-atom
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses iTunes-style metadata from MP4/M4A audio containers by walking the MPEG-4 ISO Base Media box (atom) tree. The parser navigates `moov → udta → meta → ilst` (with a fallback to `moov → meta → ilst` for non-standard files) and decodes all standard iTunes tag atoms into a normalised `Track` record.
|
||||
|
||||
The implementation is pure binary — it uses no external XML or MP4 libraries, operating directly on a `Uint8Array` with the shared `buffer-utils` helpers.
|
||||
|
||||
## Features
|
||||
|
||||
- Generic box/atom tree walker (`iterBoxes`) with graceful handling of corrupted sizes and EOF-marker boxes
|
||||
- Supports both `moov/udta/meta/ilst` and `moov/meta/ilst` layouts
|
||||
- Decodes UTF-8 text atoms: `©nam`, `©ART`, `©alb`, `aART`, `©day`, `©gen`, `©cmt`, `©lyr`
|
||||
- Parses `trkn` and `disk` integer-pair atoms into `trackNumber / totalTracks` and `discNumber / totalDiscs`
|
||||
- Handles `gnre` numeric genre index (1-based ID3v1 list) and `©gen` free-text genre
|
||||
- Cover art extraction from `covr/data` with MIME-type detection (JPEG=0x0d, PNG=0x0e)
|
||||
- Options: `skipCovers`, `maxCoverSize`
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseMp4 } from "@suaveplan/audio-metadata/parsers/mp4-atom";
|
||||
|
||||
const track = parseMp4(m4aBuffer);
|
||||
console.log(track.title); // "Bohemian Rhapsody"
|
||||
console.log(track.year); // 1975
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseMp4 } from "@suaveplan/audio-metadata/parsers/mp4-atom";
|
||||
|
||||
const track = parseMp4(m4aBuffer, {
|
||||
skipCovers: false,
|
||||
maxCoverSize: 1024 * 1024, // 1 MiB limit
|
||||
});
|
||||
|
||||
if (track.cover) {
|
||||
console.log(track.cover.mimeType); // "image/jpeg" or "image/png"
|
||||
console.log(track.cover.data.length); // bytes
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseMp4(buffer, options?)`
|
||||
|
||||
```ts
|
||||
function parseMp4(
|
||||
buffer: Uint8Array,
|
||||
options?: {
|
||||
skipCovers?: boolean;
|
||||
maxCoverSize?: number;
|
||||
},
|
||||
): Track
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | Full MP4/M4A file buffer |
|
||||
| `options.skipCovers` | `boolean` | Skip cover art extraction |
|
||||
| `options.maxCoverSize` | `number` | Maximum cover bytes to accept (default 5 MiB) |
|
||||
|
||||
**Returns:** `Track` object; `{}` if no `ilst` atom is found.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each MP4 box starts with a 4-byte big-endian size (including the 8-byte header) followed by a 4-byte ASCII type code. A size of `0` means the box extends to the end of the file; a size of `1` means a 64-bit extended size follows — 64-bit boxes are skipped because they are not used for metadata in practice. The `meta` box includes a 4-byte version+flags prefix before its children, which `parseMp4` skips by advancing `dataOffset + 4` before searching for `ilst`. The `©` prefix in atom names (Unicode U+00A9) matches the raw Latin-1 byte `0xa9` written by all major encoders. Track/disc number parsing reads a 6-byte big-endian structure: 2 bytes padding, 2 bytes number, 2 bytes total.
|
||||
@@ -0,0 +1,670 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseMp4 } from "./mp4-atom.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Minimal MP4/M4A buffer builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uint32BE(n: number): number[] {
|
||||
return [(n >> 24) & 0xff, (n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
||||
}
|
||||
|
||||
function boxBytes(type: string, data: number[]): number[] {
|
||||
const size = 8 + data.length;
|
||||
// Encode type as Latin-1: each char maps directly to its char code (handles © = 0xA9)
|
||||
const typeBytes = [
|
||||
type.charCodeAt(0) & 0xff,
|
||||
type.charCodeAt(1) & 0xff,
|
||||
type.charCodeAt(2) & 0xff,
|
||||
type.charCodeAt(3) & 0xff,
|
||||
];
|
||||
return [...uint32BE(size), ...typeBytes, ...data];
|
||||
}
|
||||
|
||||
function textDataBox(text: string): number[] {
|
||||
const textBytes = Array.from(new TextEncoder().encode(text));
|
||||
// data box: type(4) "data", flags+version(4) = 0x00 00 00 01 (UTF-8), locale(4) = 0
|
||||
return boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // flags: text/UTF-8
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
...textBytes,
|
||||
]);
|
||||
}
|
||||
|
||||
function itemBox(itemType: string, textContent: string): number[] {
|
||||
return boxBytes(itemType, textDataBox(textContent));
|
||||
}
|
||||
|
||||
function buildMinimalMp4WithIlst(ilstItems: number[]): Uint8Array {
|
||||
// ilst box
|
||||
const ilst = boxBytes("ilst", ilstItems);
|
||||
// meta box (needs 4-byte version+flags prefix before children)
|
||||
const metaData = [0x00, 0x00, 0x00, 0x00, ...ilst]; // version+flags + ilst
|
||||
const meta = boxBytes("meta", metaData);
|
||||
// udta box
|
||||
const udta = boxBytes("udta", meta);
|
||||
// moov box
|
||||
const moov = boxBytes("moov", udta);
|
||||
|
||||
return new Uint8Array(moov);
|
||||
}
|
||||
|
||||
describe("mp4-atom parser", () => {
|
||||
it("parses ©nam (title)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(itemBox("\u00a9nam", "Test Title"));
|
||||
const track = parseMp4(buf);
|
||||
expect(track.title).toBe("Test Title");
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("parses ©ART (artist)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(
|
||||
itemBox("\u00a9ART", "Test Artist")
|
||||
);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.artist).toBe("Test Artist");
|
||||
});
|
||||
|
||||
it("parses ©alb (album)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(itemBox("\u00a9alb", "Test Album"));
|
||||
const track = parseMp4(buf);
|
||||
expect(track.album).toBe("Test Album");
|
||||
});
|
||||
|
||||
it("parses aART (album artist)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(itemBox("aART", "Various Artists"));
|
||||
const track = parseMp4(buf);
|
||||
expect(track.albumArtist).toBe("Various Artists");
|
||||
});
|
||||
|
||||
it("parses ©day (year)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(itemBox("\u00a9day", "2023-05-01"));
|
||||
const track = parseMp4(buf);
|
||||
expect(track.year).toBe(2023);
|
||||
});
|
||||
|
||||
it("parses ©cmt (comment)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(itemBox("\u00a9cmt", "A comment"));
|
||||
const track = parseMp4(buf);
|
||||
expect(track.comment).toBe("A comment");
|
||||
});
|
||||
|
||||
it("parses ©gen (genre)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(itemBox("\u00a9gen", "Electronic"));
|
||||
const track = parseMp4(buf);
|
||||
expect(track.genre).toBe("Electronic");
|
||||
});
|
||||
|
||||
it("parses trkn (track number)", { timeout: 5000 }, () => {
|
||||
// trkn data: 0x00 0x00 (padding) 0x00 0x03 (track 3) 0x00 0x0c (total 12)
|
||||
const trknData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // flags: implicit integer
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
0x00,
|
||||
0x00, // padding
|
||||
0x00,
|
||||
0x03, // track number: 3
|
||||
0x00,
|
||||
0x0c, // total: 12
|
||||
]);
|
||||
const trknBox = boxBytes("trkn", trknData);
|
||||
const buf = buildMinimalMp4WithIlst(trknBox);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.trackNumber).toBe(3);
|
||||
expect(track.totalTracks).toBe(12);
|
||||
});
|
||||
|
||||
it("parses disk (disc number)", { timeout: 5000 }, () => {
|
||||
const diskData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x02, // disc 2
|
||||
0x00,
|
||||
0x03, // of 3
|
||||
]);
|
||||
const diskBox = boxBytes("disk", diskData);
|
||||
const buf = buildMinimalMp4WithIlst(diskBox);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.discNumber).toBe(2);
|
||||
expect(track.totalDiscs).toBe(3);
|
||||
});
|
||||
|
||||
it("returns empty object when no moov box", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x10, 0x66, 0x72, 0x65, 0x65,
|
||||
]); // "free" box
|
||||
expect(parseMp4(buf)).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for short buffer", { timeout: 5000 }, () => {
|
||||
expect(parseMp4(new Uint8Array([0x00, 0x01, 0x02]))).toEqual({});
|
||||
});
|
||||
|
||||
it("respects skipCovers option", { timeout: 5000 }, () => {
|
||||
// Build covr item with data box
|
||||
const covrData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d, // flags: 0x0d = JPEG
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
0xff,
|
||||
0xd8,
|
||||
0xff, // minimal JPEG bytes
|
||||
]);
|
||||
const covrBox = boxBytes("covr", covrData);
|
||||
const buf = buildMinimalMp4WithIlst(covrBox);
|
||||
|
||||
const withCover = parseMp4(buf, { skipCovers: false });
|
||||
const noCover = parseMp4(buf, { skipCovers: true });
|
||||
|
||||
// Cover parsing may succeed or not depending on minimal bytes — just check skip works
|
||||
if (withCover.cover) {
|
||||
expect(noCover.cover).toBeUndefined();
|
||||
}
|
||||
});
|
||||
|
||||
it("parses gnre (integer genre)", { timeout: 5000 }, () => {
|
||||
// gnre item box with an integer data box: index=18 (1-based) = "Rock"
|
||||
const gnreData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // flags: implicit integer
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
0x00,
|
||||
0x12, // index: 18 → "Rock"
|
||||
]);
|
||||
const gnreBox = boxBytes("gnre", gnreData);
|
||||
const buf = buildMinimalMp4WithIlst(gnreBox);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.genre).toBe("Rock");
|
||||
});
|
||||
|
||||
it("ignores gnre with index 0 (invalid)", { timeout: 5000 }, () => {
|
||||
// idx=0 is not valid (gnre is 1-based), should not set genre
|
||||
const gnreData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // index: 0
|
||||
]);
|
||||
const gnreBox = boxBytes("gnre", gnreData);
|
||||
const buf = buildMinimalMp4WithIlst(gnreBox);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.genre).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parses ©lyr (lyrics)", { timeout: 5000 }, () => {
|
||||
const buf = buildMinimalMp4WithIlst(
|
||||
itemBox("\u00a9lyr", "Amazing lyrics")
|
||||
);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.lyrics).toBe("Amazing lyrics");
|
||||
});
|
||||
|
||||
it("parses covr (PNG cover art)", { timeout: 5000 }, () => {
|
||||
// Build a covr data box with PNG flag (0x0e) and some bytes
|
||||
const imgBytes = [0x89, 0x50, 0x4e, 0x47]; // PNG magic bytes
|
||||
const covrData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0e, // flags: 0x0e = PNG
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
...imgBytes,
|
||||
]);
|
||||
const covrBox = boxBytes("covr", covrData);
|
||||
const buf = buildMinimalMp4WithIlst(covrBox);
|
||||
const track = parseMp4(buf, { skipCovers: false });
|
||||
expect(track.cover).toBeDefined();
|
||||
expect(track.cover?.mimeType).toBe("image/png");
|
||||
});
|
||||
|
||||
it("skips data box with dataSize <= 8 in readItmData", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A ©nam item with a data box that has only 8 bytes (the header) — too small for content
|
||||
// This triggers `if (child.dataSize > 8)` FALSE branch → returns null → readItmText returns ""
|
||||
const tinyDataBox = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // flags
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale (exactly 8 bytes total data content)
|
||||
]);
|
||||
// Note: tinyDataBox has 8 bytes of data → dataSize = 8, NOT > 8 → returns null
|
||||
const itemWithTinyData = boxBytes("\u00a9nam", tinyDataBox);
|
||||
const buf = buildMinimalMp4WithIlst(itemWithTinyData);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles iterBoxes with zero-size box (breaks)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A moov box containing a zero-size box (size=0 means extends to EOF, treated as terminal)
|
||||
// iterBoxes breaks when size === 0
|
||||
const zeroSizeBox = [0x00, 0x00, 0x00, 0x00, 0x74, 0x65, 0x73, 0x74]; // size=0, type="test"
|
||||
const moovData = [...zeroSizeBox];
|
||||
const moovSize = 8 + moovData.length;
|
||||
const buf = new Uint8Array([
|
||||
...uint32BE(moovSize),
|
||||
0x6d,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x76, // moov
|
||||
...moovData,
|
||||
]);
|
||||
const track = parseMp4(buf);
|
||||
// No ilst found → returns { format: "mp4" }
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("handles iterBoxes with corrupted small-size box (size < 8, breaks)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A moov containing a box with size=4 (too small to be valid)
|
||||
const corruptBox = [0x00, 0x00, 0x00, 0x04, 0x74, 0x65, 0x73, 0x74]; // size=4
|
||||
const moovData = [...corruptBox];
|
||||
const moovSize = 8 + moovData.length;
|
||||
const buf = new Uint8Array([
|
||||
...uint32BE(moovSize),
|
||||
0x6d,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x76,
|
||||
...moovData,
|
||||
]);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("skips 64-bit extended-size box in iterBoxes", { timeout: 5000 }, () => {
|
||||
// A moov with a box that has size=1 (64-bit extended size indicator)
|
||||
// iterBoxes skips it and advances by 16
|
||||
const extendedBox = [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // size=1 (64-bit indicator)
|
||||
0x74,
|
||||
0x65,
|
||||
0x73,
|
||||
0x74, // type="test"
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x10, // 64-bit size=16
|
||||
]; // 16 bytes total, skipped
|
||||
const moovData = [...extendedBox];
|
||||
const moovSize = 8 + moovData.length;
|
||||
const buf = new Uint8Array([
|
||||
...uint32BE(moovSize),
|
||||
0x6d,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x76,
|
||||
...moovData,
|
||||
]);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("returns format mp4 with no ilst", { timeout: 5000 }, () => {
|
||||
// A minimal moov with no meta/ilst — parseMp4 should return { format: "mp4" }
|
||||
const moovData: number[] = [];
|
||||
const moovSize = 8 + moovData.length;
|
||||
const uint32BE2 = (n: number) => [
|
||||
(n >> 24) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
n & 0xff,
|
||||
];
|
||||
const buf = new Uint8Array([
|
||||
...uint32BE2(moovSize),
|
||||
0x6d,
|
||||
0x6f,
|
||||
0x6f,
|
||||
0x76,
|
||||
...moovData,
|
||||
]);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("returns empty string from readItmText when data box missing", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A ©nam item box with NO data child box — readItmText should return ""
|
||||
// and therefore title should remain undefined
|
||||
const emptyItemBox = boxBytes("\u00a9nam", []); // no data child
|
||||
const buf = buildMinimalMp4WithIlst(emptyItemBox);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.title).toBeUndefined();
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("handles trkn item with data box too small for readTrackOrDisc", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// readTrackOrDisc needs data.length >= 6; < 6 returns {num:0, total:0}
|
||||
// (num=0 → not set, total=0 → not set)
|
||||
const tinyTrackData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // only 2 bytes after header (< 6 needed for num+total)
|
||||
]);
|
||||
const trknBox = boxBytes("trkn", tinyTrackData);
|
||||
const buf = buildMinimalMp4WithIlst(trknBox);
|
||||
const track = parseMp4(buf);
|
||||
// num=0 means trackNumber not set
|
||||
expect(track.trackNumber).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles covr with data box dataSize <= 8 (continues, returns null)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// parseCovr: if child.dataSize <= 8, continue — no cover returned
|
||||
const tinyCovrData = boxBytes(
|
||||
"data",
|
||||
[
|
||||
0x00, 0x00, 0x00, 0x0d, 0x00, 0x00, 0x00,
|
||||
0x00,
|
||||
// only 8 bytes → dataSize = 8, NOT > 8 → continue
|
||||
]
|
||||
);
|
||||
const covrBox = boxBytes("covr", tinyCovrData);
|
||||
const buf = buildMinimalMp4WithIlst(covrBox);
|
||||
const track = parseMp4(buf, { skipCovers: false });
|
||||
expect(track.cover).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns null from parseCovr when no data child box found", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// covr item with no data box — parseCovr returns null → cover undefined
|
||||
const covrBoxNoData = boxBytes("covr", []); // no data child
|
||||
const buf = buildMinimalMp4WithIlst(covrBoxNoData);
|
||||
const track = parseMp4(buf, { skipCovers: false });
|
||||
expect(track.cover).toBeUndefined();
|
||||
});
|
||||
|
||||
it("parseCovr skips non-data child box type before reaching data child", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// covr item with a non-"data" typed child first (triggers `if (child.type !== "data") continue`)
|
||||
// then a valid data child
|
||||
const nonDataChild = boxBytes("free", [0x00, 0x00]); // type "free" not "data"
|
||||
const imgBytes = [0xff, 0xd8, 0xff]; // minimal JPEG
|
||||
const dataChild = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d, // flags: JPEG
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
...imgBytes,
|
||||
]);
|
||||
const covrBox = boxBytes("covr", [...nonDataChild, ...dataChild]);
|
||||
const buf = buildMinimalMp4WithIlst(covrBox);
|
||||
const track = parseMp4(buf, { skipCovers: false });
|
||||
// After skipping non-data child, data child is found → cover set
|
||||
expect(track.cover).toBeDefined();
|
||||
});
|
||||
|
||||
it("readItmData skips non-data children (child.type !== data else path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A ©nam item with a non-"data" child — readItmData returns null → title undefined
|
||||
const nonDataChild = boxBytes("name", [0x41, 0x42]); // type "name" not "data"
|
||||
const itemBox = boxBytes("\u00a9nam", nonDataChild);
|
||||
const buf = buildMinimalMp4WithIlst(itemBox);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("finds ilst via moov/meta fallback path (no udta)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build moov/meta/ilst directly (skip udta) — exercises the fallback path
|
||||
const ilst = boxBytes("ilst", itemBox("\u00a9nam", "FallbackTitle"));
|
||||
const metaData = [0x00, 0x00, 0x00, 0x00, ...ilst]; // version+flags + ilst
|
||||
const meta = boxBytes("meta", metaData);
|
||||
const moov = boxBytes("moov", meta); // no udta — meta is directly inside moov
|
||||
const buf = new Uint8Array(moov);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.title).toBe("FallbackTitle");
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("handles udta with no meta inside (metaInUdta is null)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// moov/udta with no meta inside — metaInUdta=null → else path of if(metaInUdta)
|
||||
const udta = boxBytes("udta", []); // udta with no meta child
|
||||
const moov = boxBytes("moov", udta);
|
||||
const buf = new Uint8Array(moov);
|
||||
const track = parseMp4(buf);
|
||||
// No meta anywhere → format: "mp4" (no ilst found)
|
||||
expect(track.format).toBe("mp4");
|
||||
});
|
||||
|
||||
it("handles ©nam item that resolves to empty string (val falsy — else path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// A data box with only null bytes — readItmText returns "" → val is falsy
|
||||
const emptyDataBox = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01, // flags: text
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
0x00, // null byte content → trims to ""
|
||||
]);
|
||||
const emptyItem = boxBytes("\u00a9nam", emptyDataBox);
|
||||
const buf = buildMinimalMp4WithIlst(emptyItem);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles ©ART, ©alb, aART items resolving to empty (val falsy)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const emptyDataBox = boxBytes(
|
||||
"data",
|
||||
[0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
);
|
||||
const items = [
|
||||
...boxBytes("\u00a9ART", emptyDataBox),
|
||||
...boxBytes("\u00a9alb", emptyDataBox),
|
||||
...boxBytes("aART", emptyDataBox),
|
||||
];
|
||||
const buf = buildMinimalMp4WithIlst(items);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.artist).toBeUndefined();
|
||||
expect(track.album).toBeUndefined();
|
||||
expect(track.albumArtist).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles ©day with non-numeric year (NaN → year not set)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = buildMinimalMp4WithIlst(
|
||||
itemBox("\u00a9day", "unknwn-01-01")
|
||||
);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.year).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles ©day item with empty text (val falsy — else path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const emptyDataBox = boxBytes(
|
||||
"data",
|
||||
[0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
);
|
||||
const buf = buildMinimalMp4WithIlst(
|
||||
boxBytes("\u00a9day", emptyDataBox)
|
||||
);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.year).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles disk with num=0 and total=0 (else paths of discNumber/totalDiscs)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// disk data with 0x00 for both num and total
|
||||
const diskData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // flags
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // locale
|
||||
0x00,
|
||||
0x00, // padding
|
||||
0x00,
|
||||
0x00, // num = 0
|
||||
0x00,
|
||||
0x00, // total = 0
|
||||
]);
|
||||
const diskBox = boxBytes("disk", diskData);
|
||||
const buf = buildMinimalMp4WithIlst(diskBox);
|
||||
const track = parseMp4(buf);
|
||||
// num=0 → discNumber not set; total=0 → totalDiscs not set
|
||||
expect(track.discNumber).toBeUndefined();
|
||||
expect(track.totalDiscs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles gnre with index out of range (idx > GENRES.length — else path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// idx = 999 > GENRES.length (20) → if(genreName) is FALSE
|
||||
const gnreData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x03,
|
||||
0xe7, // index: 999
|
||||
]);
|
||||
const gnreBox = boxBytes("gnre", gnreData);
|
||||
const buf = buildMinimalMp4WithIlst(gnreBox);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.genre).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles gnre where readItmData returns null (data && data.length >= 2 is false)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// gnre item with no data child → readItmData returns null → `if (data && ...)` FALSE
|
||||
const gnreBoxNoData = boxBytes("gnre", []); // no data child
|
||||
const buf = buildMinimalMp4WithIlst(gnreBoxNoData);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.genre).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles ©gen, ©cmt, ©lyr items resolving to empty (val falsy)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const emptyDataBox = boxBytes(
|
||||
"data",
|
||||
[0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
);
|
||||
const items = [
|
||||
...boxBytes("\u00a9gen", emptyDataBox),
|
||||
...boxBytes("\u00a9cmt", emptyDataBox),
|
||||
...boxBytes("\u00a9lyr", emptyDataBox),
|
||||
];
|
||||
const buf = buildMinimalMp4WithIlst(items);
|
||||
const track = parseMp4(buf);
|
||||
expect(track.genre).toBeUndefined();
|
||||
expect(track.comment).toBeUndefined();
|
||||
expect(track.lyrics).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles covr with oversized image (exceeds maxCoverSize)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// maxCoverSize = 4, but img has 4 bytes — exactly at limit, not over
|
||||
// To exceed: make img 5 bytes with maxCoverSize 4
|
||||
const imgBytes = [0xff, 0xd8, 0xff, 0xe0, 0x00]; // 5 bytes
|
||||
const covrData = boxBytes("data", [
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0d, // JPEG flag
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
...imgBytes,
|
||||
]);
|
||||
const covrBox = boxBytes("covr", covrData);
|
||||
const buf = buildMinimalMp4WithIlst(covrBox);
|
||||
const track = parseMp4(buf, { skipCovers: false, maxCoverSize: 4 });
|
||||
expect(track.cover).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,351 @@
|
||||
import type { CoverArt, Track } from "../../types/metadata.types.js";
|
||||
import { readString, readUint32BE } from "../../utils/buffer-utils.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Box/atom walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Box {
|
||||
type: string;
|
||||
offset: number;
|
||||
size: number;
|
||||
dataOffset: number;
|
||||
dataSize: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate the top-level boxes in a buffer, yielding their positions.
|
||||
* Boxes with 64-bit extended sizes are not common in typical M4A/MP4 files —
|
||||
* we skip them gracefully rather than erroring.
|
||||
*/
|
||||
function* iterBoxes(
|
||||
buf: Uint8Array,
|
||||
start: number,
|
||||
end: number
|
||||
): Generator<Box> {
|
||||
let pos = start;
|
||||
while (pos + 8 <= end) {
|
||||
const size = readUint32BE(buf, pos);
|
||||
const type = readString(buf, pos + 4, 4, "latin1");
|
||||
if (size === 0) break; // box extends to EOF — treat as terminal
|
||||
if (size < 8) break; // corrupted (also catches size===1 for 64-bit extended)
|
||||
// size >= 8 is guaranteed here; size===1 (64-bit extended) was already caught above
|
||||
yield {
|
||||
type,
|
||||
offset: pos,
|
||||
size,
|
||||
dataOffset: pos + 8,
|
||||
dataSize: size - 8,
|
||||
};
|
||||
pos += size;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a named child box within a container box, following a chain of names.
|
||||
* Returns the first match or `null`.
|
||||
*/
|
||||
function findBox(
|
||||
buf: Uint8Array,
|
||||
start: number,
|
||||
end: number,
|
||||
...path: string[]
|
||||
): Box | null {
|
||||
let currentStart = start;
|
||||
let currentEnd = end;
|
||||
let found: Box | null = null;
|
||||
|
||||
for (const name of path) {
|
||||
found = null;
|
||||
for (const box of iterBoxes(buf, currentStart, currentEnd)) {
|
||||
if (box.type === name) {
|
||||
found = box;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!found) return null;
|
||||
currentStart = found.dataOffset;
|
||||
currentEnd = found.offset + found.size;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// iTunes ilst parsing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Extract the value from an iTunes metadata item box.
|
||||
*
|
||||
* The value lives inside a `data` child box:
|
||||
* version+flags (4 bytes) + locale (4 bytes) + payload
|
||||
*/
|
||||
function readItmData(buf: Uint8Array, itemBox: Box): Uint8Array | null {
|
||||
for (const child of iterBoxes(
|
||||
buf,
|
||||
itemBox.dataOffset,
|
||||
itemBox.offset + itemBox.size
|
||||
)) {
|
||||
if (child.type === "data") {
|
||||
// Skip 8-byte data header (version/flags + locale)
|
||||
if (child.dataSize > 8) {
|
||||
return buf.subarray(
|
||||
child.dataOffset + 8,
|
||||
child.dataOffset + child.dataSize
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readItmText(buf: Uint8Array, itemBox: Box): string {
|
||||
const data = readItmData(buf, itemBox);
|
||||
if (!data || data.length === 0) return "";
|
||||
return new TextDecoder("utf-8").decode(data).replace(/\0/g, "").trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the trkn or disk integer pair box.
|
||||
* Layout: 0x00 0x00 (uint16) number (uint16) total
|
||||
*/
|
||||
function readTrackOrDisc(
|
||||
buf: Uint8Array,
|
||||
itemBox: Box
|
||||
): { num: number; total: number } {
|
||||
const data = readItmData(buf, itemBox);
|
||||
if (!data || data.length < 6) return { num: 0, total: 0 };
|
||||
// data.length >= 6 is guaranteed; data[2..5] are always defined
|
||||
const num = ((data[2] as number) << 8) | (data[3] as number);
|
||||
const total = ((data[4] as number) << 8) | (data[5] as number);
|
||||
return { num, total };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse iTunes/MP4 metadata from the `moov/udta/meta/ilst` atom tree.
|
||||
*
|
||||
* Returns a normalised `Track`. Returns an empty object if no `ilst` is found.
|
||||
*/
|
||||
export function parseMp4(
|
||||
buffer: Uint8Array,
|
||||
options?: { skipCovers?: boolean; maxCoverSize?: number }
|
||||
): Track {
|
||||
const maxCoverSize = options?.maxCoverSize ?? 5 * 1024 * 1024;
|
||||
const end = buffer.length;
|
||||
|
||||
// Walk: moov → udta → meta → ilst
|
||||
const moov = findBox(buffer, 0, end, "moov");
|
||||
if (!moov) return {};
|
||||
|
||||
const moovStart = moov.dataOffset;
|
||||
const moovEnd = moov.offset + moov.size;
|
||||
|
||||
// Helper: find meta box and then ilst within it (skipping 4-byte version+flags prefix)
|
||||
function findIlstInMeta(metaBox: Box): Box | null {
|
||||
// meta has a 4-byte version+flags header before child boxes
|
||||
return findBox(
|
||||
buffer,
|
||||
metaBox.dataOffset + 4,
|
||||
metaBox.offset + metaBox.size,
|
||||
"ilst"
|
||||
);
|
||||
}
|
||||
|
||||
let ilst: Box | null = null;
|
||||
|
||||
// Try moov/udta/meta/ilst
|
||||
const udta = findBox(buffer, moovStart, moovEnd, "udta");
|
||||
if (udta) {
|
||||
const metaInUdta = findBox(
|
||||
buffer,
|
||||
udta.dataOffset,
|
||||
udta.offset + udta.size,
|
||||
"meta"
|
||||
);
|
||||
if (metaInUdta) ilst = findIlstInMeta(metaInUdta);
|
||||
}
|
||||
|
||||
// Fallback: moov/meta/ilst
|
||||
if (!ilst) {
|
||||
const meta = findBox(buffer, moovStart, moovEnd, "meta");
|
||||
if (meta) ilst = findIlstInMeta(meta);
|
||||
}
|
||||
|
||||
if (!ilst) return { format: "mp4" };
|
||||
|
||||
const raw: Record<string, string | string[]> = {};
|
||||
const track: Track = { format: "mp4" };
|
||||
|
||||
for (const item of iterBoxes(
|
||||
buffer,
|
||||
ilst.dataOffset,
|
||||
ilst.offset + ilst.size
|
||||
)) {
|
||||
switch (item.type) {
|
||||
case "\u00a9nam": {
|
||||
// ©nam — title
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
track.title = val;
|
||||
raw["©nam"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "\u00a9ART": {
|
||||
// ©ART — artist
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
track.artist = val;
|
||||
raw["©ART"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "\u00a9alb": {
|
||||
// ©alb — album
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
track.album = val;
|
||||
raw["©alb"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "aART": {
|
||||
// album artist
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
track.albumArtist = val;
|
||||
raw["aART"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "\u00a9day": {
|
||||
// ©day — year/date
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
raw["©day"] = val;
|
||||
const y = Number.parseInt(val.substring(0, 4), 10);
|
||||
if (!Number.isNaN(y)) track.year = y;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "trkn": {
|
||||
// track number
|
||||
const { num, total } = readTrackOrDisc(buffer, item);
|
||||
if (num > 0) track.trackNumber = num;
|
||||
if (total > 0) track.totalTracks = total;
|
||||
raw["trkn"] = `${num}/${total}`;
|
||||
break;
|
||||
}
|
||||
case "disk": {
|
||||
// disc number
|
||||
const { num, total } = readTrackOrDisc(buffer, item);
|
||||
if (num > 0) track.discNumber = num;
|
||||
if (total > 0) track.totalDiscs = total;
|
||||
raw["disk"] = `${num}/${total}`;
|
||||
break;
|
||||
}
|
||||
case "\u00a9gen": {
|
||||
// ©gen — genre (text)
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
track.genre = val;
|
||||
raw["©gen"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "gnre": {
|
||||
// genre (integer index)
|
||||
const data = readItmData(buffer, item);
|
||||
if (data && data.length >= 2) {
|
||||
// data.length >= 2 guarantees data[0] and data[1] are defined
|
||||
const idx =
|
||||
((data[0] as number) << 8) | (data[1] as number);
|
||||
// gnre uses 1-based index into the ID3v1 genre list
|
||||
const GENRES = [
|
||||
"Blues",
|
||||
"Classic Rock",
|
||||
"Country",
|
||||
"Dance",
|
||||
"Disco",
|
||||
"Funk",
|
||||
"Grunge",
|
||||
"Hip-Hop",
|
||||
"Jazz",
|
||||
"Metal",
|
||||
"New Age",
|
||||
"Oldies",
|
||||
"Other",
|
||||
"Pop",
|
||||
"R&B",
|
||||
"Rap",
|
||||
"Reggae",
|
||||
"Rock",
|
||||
"Techno",
|
||||
"Industrial",
|
||||
];
|
||||
if (idx > 0 && idx <= GENRES.length) {
|
||||
track.genre = GENRES[idx - 1] as string;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "\u00a9cmt": {
|
||||
// ©cmt — comment
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
track.comment = val;
|
||||
raw["©cmt"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "\u00a9lyr": {
|
||||
// ©lyr — lyrics
|
||||
const val = readItmText(buffer, item);
|
||||
if (val) {
|
||||
track.lyrics = val;
|
||||
raw["©lyr"] = val;
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "covr": {
|
||||
// cover art
|
||||
if (options?.skipCovers) break;
|
||||
const cover = parseCovr(buffer, item, maxCoverSize);
|
||||
if (cover) track.cover = cover;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
track.rawTags = raw;
|
||||
return track;
|
||||
}
|
||||
|
||||
function parseCovr(
|
||||
buf: Uint8Array,
|
||||
itemBox: Box,
|
||||
maxSize: number
|
||||
): CoverArt | null {
|
||||
for (const child of iterBoxes(
|
||||
buf,
|
||||
itemBox.dataOffset,
|
||||
itemBox.offset + itemBox.size
|
||||
)) {
|
||||
if (child.type !== "data") continue;
|
||||
if (child.dataSize <= 8) continue;
|
||||
// flags at offset 1-3 of data box: 0x0d = JPEG, 0x0e = PNG
|
||||
const flags = readUint32BE(buf, child.dataOffset) & 0xffffff;
|
||||
const mimeType = flags === 0x0e ? "image/png" : "image/jpeg";
|
||||
const imgData = buf.subarray(
|
||||
child.dataOffset + 8,
|
||||
child.dataOffset + child.dataSize
|
||||
);
|
||||
if (imgData.length === 0 || imgData.length > maxSize) return null;
|
||||
return { data: imgData, mimeType };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { parseOgg } from "./ogg-vorbis";
|
||||
@@ -0,0 +1,61 @@
|
||||
# ogg-vorbis
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses Ogg Vorbis audio files by reconstructing the logical bitstream from Ogg pages and locating the Vorbis comment header packet. Ogg is a container format; the actual metadata follows the Xiph Vorbis comment standard, which is shared with FLAC — this parser reuses `parseVorbisCommentBlock` from the `flac-vorbis` module rather than duplicating the comment-decoding logic.
|
||||
|
||||
Audio stream properties (sample rate, channel count) are extracted from the Vorbis identification header (the first packet), so the returned `Track` includes both tag and stream data where available.
|
||||
|
||||
## Features
|
||||
|
||||
- Verifies "OggS" capture pattern before parsing
|
||||
- Page-level Ogg bitstream walker that correctly handles multi-segment packets (255-byte continuation)
|
||||
- Finds the Vorbis comment packet by its type-byte signature `0x03 + "vorbis"`
|
||||
- Extracts sample rate and channel count from the Vorbis identification header (packet type `0x01`)
|
||||
- Falls back to a single-page linear scan if the packet walker does not find the comment header
|
||||
- Delegates comment decoding to `parseVorbisCommentBlock` from the shared FLAC/Ogg Vorbis module
|
||||
- Returns `{ format: "ogg" }` rather than `{}` when the magic matches but no comment packet is found
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseOgg } from "@suaveplan/audio-metadata/parsers/ogg-vorbis";
|
||||
|
||||
const track = parseOgg(oggBuffer);
|
||||
console.log(track.title); // "The Fox"
|
||||
console.log(track.sampleRate); // 44100
|
||||
console.log(track.channels); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseOgg } from "@suaveplan/audio-metadata/parsers/ogg-vorbis";
|
||||
|
||||
const track = parseOgg(oggBuffer);
|
||||
if (track.rawTags?.["REPLAYGAIN_ALBUM_GAIN"]) {
|
||||
const gain = parseFloat(track.rawTags["REPLAYGAIN_ALBUM_GAIN"] as string);
|
||||
// Apply ReplayGain before audio playback
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseOgg(buffer)`
|
||||
|
||||
```ts
|
||||
function parseOgg(buffer: Uint8Array): Track
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | Full Ogg Vorbis file buffer |
|
||||
|
||||
**Returns:** `Track` object with `format: "ogg"`. Returns `{}` if the buffer does not start with "OggS".
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Ogg pages use a segment table where each entry is 0–255 bytes. A segment of exactly 255 bytes indicates the packet continues in the next segment; any shorter segment terminates the current packet. The walker handles this correctly by accumulating bytes across segment boundaries. The maximum packet collection is capped at 3 (ident, comment, setup) because these are the only headers needed for metadata extraction — scanning further would be wasteful for large audio files. The fallback linear scan handles the unusual case where the identification and comment packets are packed into the same Ogg page.
|
||||
@@ -0,0 +1,803 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseOgg } from "./ogg-vorbis.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uint32LE(n: number): number[] {
|
||||
return [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff];
|
||||
}
|
||||
|
||||
function buildVorbisCommentData(comments: string[]): number[] {
|
||||
const vendor = "test";
|
||||
const vendorBytes = Array.from(new TextEncoder().encode(vendor));
|
||||
const out: number[] = [
|
||||
...uint32LE(vendorBytes.length),
|
||||
...vendorBytes,
|
||||
...uint32LE(comments.length),
|
||||
];
|
||||
for (const c of comments) {
|
||||
const bytes = Array.from(new TextEncoder().encode(c));
|
||||
out.push(...uint32LE(bytes.length), ...bytes);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a minimal Ogg page containing a Vorbis comment packet.
|
||||
* The page contains just the comment packet directly.
|
||||
*/
|
||||
function buildOggPageWithCommentPacket(comments: string[]): Uint8Array {
|
||||
// Vorbis comment packet: 0x03 "vorbis" + comment data
|
||||
const commentData = buildVorbisCommentData(comments);
|
||||
const packetData = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73, // "vorbis"
|
||||
...commentData,
|
||||
];
|
||||
|
||||
// Segment table: one segment per 255 bytes
|
||||
const segments: number[] = [];
|
||||
let remaining = packetData.length;
|
||||
while (remaining > 0) {
|
||||
const seg = Math.min(remaining, 255);
|
||||
segments.push(seg);
|
||||
remaining -= seg;
|
||||
}
|
||||
// If packet ends exactly on 255, add a 0-length segment to terminate
|
||||
if (packetData.length > 0 && packetData.length % 255 === 0) {
|
||||
segments.push(0);
|
||||
}
|
||||
|
||||
// Ogg page header
|
||||
const numSegs = segments.length;
|
||||
const page = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53, // "OggS"
|
||||
0x00, // version
|
||||
0x00, // header type (beginning of stream)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // granule position
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // serial number
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // page sequence number
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // checksum (ignored by our parser)
|
||||
numSegs, // number of page segments
|
||||
...segments, // segment table
|
||||
...packetData, // packet data
|
||||
];
|
||||
|
||||
return new Uint8Array(page);
|
||||
}
|
||||
|
||||
describe("ogg-vorbis parser", () => {
|
||||
it("parses title and artist from Ogg page", { timeout: 5000 }, () => {
|
||||
const buf = buildOggPageWithCommentPacket([
|
||||
"TITLE=My Ogg Track",
|
||||
"ARTIST=Ogg Artist",
|
||||
]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.title).toBe("My Ogg Track");
|
||||
expect(track.artist).toBe("Ogg Artist");
|
||||
expect(track.format).toBe("ogg");
|
||||
});
|
||||
|
||||
it("parses album and year", { timeout: 5000 }, () => {
|
||||
const buf = buildOggPageWithCommentPacket([
|
||||
"ALBUM=Ogg Album",
|
||||
"DATE=2021",
|
||||
]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.album).toBe("Ogg Album");
|
||||
expect(track.year).toBe(2021);
|
||||
});
|
||||
|
||||
it("parses genre", { timeout: 5000 }, () => {
|
||||
const buf = buildOggPageWithCommentPacket(["GENRE=Electronic"]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.genre).toBe("Electronic");
|
||||
});
|
||||
|
||||
it("returns empty object for non-Ogg buffer", { timeout: 5000 }, () => {
|
||||
const track = parseOgg(new Uint8Array([0x00, 0x01, 0x02, 0x03]));
|
||||
expect(track).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for short buffer", { timeout: 5000 }, () => {
|
||||
expect(parseOgg(new Uint8Array([0x4f, 0x67]))).toEqual({});
|
||||
});
|
||||
|
||||
it("returns format ogg when magic matches but no comment packet", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// OggS page with an identification packet (type 0x01), not comment
|
||||
const identPacket = [
|
||||
0x01,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73, // 0x01 "vorbis"
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // version
|
||||
0x02, // channels
|
||||
0x44,
|
||||
0xac,
|
||||
0x00,
|
||||
0x00, // sample rate 44100 LE
|
||||
];
|
||||
const segments = [identPacket.length];
|
||||
const page = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x02, // header type: BOS
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
segments.length,
|
||||
...segments,
|
||||
...identPacket,
|
||||
];
|
||||
const track = parseOgg(new Uint8Array(page));
|
||||
expect(track.format).toBe("ogg");
|
||||
});
|
||||
|
||||
it("reads sampleRate and channels from ident packet", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a 2-packet Ogg stream: ident packet (0x01 vorbis) + comment packet
|
||||
// Ident packet: type(1) + "vorbis"(6) + version(4LE) + channels(1) + sampleRate(4LE)
|
||||
const sampleRate = 44100;
|
||||
const channels = 2;
|
||||
const identPacket = [
|
||||
0x01, // type: ident (index 0)
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73, // "vorbis" (index 1-6)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // version = 0 (index 7-10)
|
||||
channels, // channels (index 11)
|
||||
sampleRate & 0xff,
|
||||
(sampleRate >> 8) & 0xff,
|
||||
(sampleRate >> 16) & 0xff,
|
||||
(sampleRate >> 24) & 0xff, // sampleRate LE (index 12-15)
|
||||
// rest of ident (min 30 bytes needed, currently at index 16)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // min bitrate (index 16-19)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // nominal bitrate (index 20-23)
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // max bitrate (index 24-27)
|
||||
0x00,
|
||||
0x00, // blocksize + padding (index 28-29) — gives 30 bytes total
|
||||
];
|
||||
const commentData = buildVorbisCommentData(["TITLE=IdentTest"]);
|
||||
const commentPacket = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73,
|
||||
...commentData,
|
||||
];
|
||||
|
||||
function buildOggPageWithPackets(packets: number[][]): number[] {
|
||||
// Flatten all packets into one page, each terminated by segment < 255
|
||||
const allSegments: number[] = [];
|
||||
const allData: number[] = [];
|
||||
for (const pkt of packets) {
|
||||
let remaining = pkt.length;
|
||||
let offset = 0;
|
||||
while (remaining >= 255) {
|
||||
allSegments.push(255);
|
||||
allData.push(...pkt.slice(offset, offset + 255));
|
||||
offset += 255;
|
||||
remaining -= 255;
|
||||
}
|
||||
allSegments.push(remaining);
|
||||
allData.push(...pkt.slice(offset));
|
||||
}
|
||||
return [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53, // OggS
|
||||
0x00,
|
||||
0x00, // version, header type
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // granule
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // serial
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // page seq
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // checksum
|
||||
allSegments.length,
|
||||
...allSegments,
|
||||
...allData,
|
||||
];
|
||||
}
|
||||
|
||||
const page = buildOggPageWithPackets([identPacket, commentPacket]);
|
||||
const track = parseOgg(new Uint8Array(page));
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("IdentTest");
|
||||
expect(track.sampleRate).toBe(sampleRate);
|
||||
expect(track.channels).toBe(channels);
|
||||
});
|
||||
|
||||
it("handles multi-page stream where second page has corrupt magic (line 62 break)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a valid page with a comment packet, then append 27+ bytes of garbage
|
||||
// so collectOggPackets hits the "break on bad magic" check at line 62
|
||||
const commentData = buildVorbisCommentData(["TITLE=MultiPage"]);
|
||||
const commentPacket = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73,
|
||||
...commentData,
|
||||
];
|
||||
const segments = [commentPacket.length];
|
||||
const page1 = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
segments.length,
|
||||
...segments,
|
||||
...commentPacket,
|
||||
];
|
||||
// Append 27+ bytes of garbage (not OggS) — enough to pass `pos + 27 > buf.length` check
|
||||
// but fail the OggS magic check → triggers line 62 break
|
||||
const garbage = new Array(30).fill(0xde);
|
||||
const buf = new Uint8Array([...page1, ...garbage]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("MultiPage");
|
||||
});
|
||||
|
||||
it("collectOggPackets breaks when pos+27+numSegs > buf.length (truncated seg table)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a page where numSegs=10 but buffer doesn't have 10 segment bytes
|
||||
// After the first page, add a second page header claiming numSegs=10 with only 2 seg bytes
|
||||
const commentData = buildVorbisCommentData(["TITLE=TruncSeg"]);
|
||||
const commentPacket = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73,
|
||||
...commentData,
|
||||
];
|
||||
const segments = [commentPacket.length];
|
||||
const page1 = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
segments.length,
|
||||
...segments,
|
||||
...commentPacket,
|
||||
];
|
||||
// Second page: valid OggS magic, but claims numSegs=10 with only 2 bytes following
|
||||
const page2 = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
10, // numSegs=10, but only 2 bytes follow (< 10 needed) → break
|
||||
0x01,
|
||||
0x01,
|
||||
];
|
||||
const buf = new Uint8Array([...page1, ...page2]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("TruncSeg");
|
||||
});
|
||||
|
||||
it("collectOggPackets hits maxPackets break (packets.length >= maxPackets)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build 3 packets worth of data in a single page to reach maxPackets=3
|
||||
// After 3 packets collected, the inner `if (packets.length >= maxPackets) break` fires
|
||||
const commentData = buildVorbisCommentData(["TITLE=MaxPkts"]);
|
||||
const p1 = [0x01, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x00]; // ident-like
|
||||
const p2 = [0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, ...commentData]; // comment
|
||||
const p3 = [0x05, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x00]; // setup-like
|
||||
// Build segment table: each packet terminated by seg < 255
|
||||
const segs: number[] = [];
|
||||
const data: number[] = [];
|
||||
for (const pkt of [p1, p2, p3]) {
|
||||
segs.push(pkt.length); // each < 255 → terminates packet
|
||||
data.push(...pkt);
|
||||
}
|
||||
const page = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
segs.length,
|
||||
...segs,
|
||||
...data,
|
||||
];
|
||||
const track = parseOgg(new Uint8Array(page));
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("MaxPkts");
|
||||
});
|
||||
|
||||
it("readOggPage returns null when segment table extends beyond buffer (pos+27+numSegs > len)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a page where numSegs is large but buffer ends before segment table completes
|
||||
// This triggers readOggPage's `if (pos + 27 + numSegs > buf.length) return null`
|
||||
// Then parseOgg fallback scan: `if (page)` is FALSE → return { format: "ogg" }
|
||||
const page = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
50, // numSegs=50, but we only provide 3 segment bytes
|
||||
0x01,
|
||||
0x01,
|
||||
0x01,
|
||||
];
|
||||
const buf = new Uint8Array(page);
|
||||
// collectOggPackets also fails (same truncated buffer) → no packets found
|
||||
// readOggPage(buffer, 0) → pos + 27 + 50 > buffer.length → returns null
|
||||
// → if (page) is FALSE → return { format: "ogg" }
|
||||
const track = parseOgg(buf);
|
||||
expect(track.format).toBe("ogg");
|
||||
});
|
||||
|
||||
it("ident packet with sampleRate=0 does not set sampleRate on track (else path)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build ident packet with sampleRate=0 and channels=0
|
||||
const identPacket = [
|
||||
0x01,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73, // 0x01 "vorbis"
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // version
|
||||
0x00, // channels = 0
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // sampleRate = 0
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // padding to 30 bytes
|
||||
];
|
||||
const commentData = buildVorbisCommentData(["TITLE=ZeroRate"]);
|
||||
const commentPacket = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73,
|
||||
...commentData,
|
||||
];
|
||||
const segs: number[] = [];
|
||||
const data: number[] = [];
|
||||
for (const pkt of [identPacket, commentPacket]) {
|
||||
segs.push(pkt.length);
|
||||
data.push(...pkt);
|
||||
}
|
||||
const page = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
segs.length,
|
||||
...segs,
|
||||
...data,
|
||||
];
|
||||
const track = parseOgg(new Uint8Array(page));
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("ZeroRate");
|
||||
// sampleRate=0 → not set (else path of if (sampleRate > 0))
|
||||
expect(track.sampleRate).toBeUndefined();
|
||||
// channels=0 → not set (else path of if (channels > 0))
|
||||
expect(track.channels).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses fallback linear scan when collectOggPackets misses comment", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a page where the comment data appears without a segment-terminating boundary
|
||||
// that collectOggPackets would normally use. We embed the 0x03 vorbis header
|
||||
// inside a single 255-length segment (no terminator segment).
|
||||
const commentData = buildVorbisCommentData(["TITLE=FallbackScan"]);
|
||||
const commentPacket = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73,
|
||||
...commentData,
|
||||
];
|
||||
// Force packet length to exactly 255 (padding to 255 with zeros appended)
|
||||
while (commentPacket.length < 255) commentPacket.push(0x00);
|
||||
// Now: 255-byte segment, NO terminating 0-length segment
|
||||
// collectOggPackets won't emit this as a packet (continues looking)
|
||||
// readOggPage will return the page data, then linear scan finds 0x03 vorbis
|
||||
const page = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
1, // numSegs = 1
|
||||
255, // one 255-byte segment (no terminator)
|
||||
...commentPacket.slice(0, 255),
|
||||
];
|
||||
const track = parseOgg(new Uint8Array(page));
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("FallbackScan");
|
||||
});
|
||||
|
||||
it("readOggPage returns null when buffer is shorter than 27 bytes (pos+27 > buf.length)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// OggS magic present but buffer too short for a full page header (< 27 bytes)
|
||||
// collectOggPackets breaks immediately (same condition), so no packets found.
|
||||
// readOggPage(buffer, 0) hits `if (pos + 27 > buf.length) return null`.
|
||||
// if (page) is FALSE → returns { format: "ogg" }
|
||||
const buf = new Uint8Array([
|
||||
0x4f, 0x67, 0x67, 0x53, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.format).toBe("ogg");
|
||||
});
|
||||
|
||||
it("readOggPage returns null when declared data extends past buffer end (dataStart+dataLen > len)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a page where segment table says 100 bytes of data but buffer ends after segment table.
|
||||
// collectOggPackets: pos+27+numSegs check passes (1 seg byte), then post-seg-table
|
||||
// pos advances but data is missing → no packets collected.
|
||||
// readOggPage: passes pos+27, passes magic, passes numSegs, computes dataLen=100,
|
||||
// dataStart+100 > buf.length → returns null → if (page) is FALSE.
|
||||
const buf = new Uint8Array([
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00, // OggS magic + version + type
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // granule_pos
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // serial
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // page_seq
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00, // checksum
|
||||
1, // numSegs = 1
|
||||
100, // segment[0] = 100 bytes, but buffer ends here (no actual data)
|
||||
]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.format).toBe("ogg");
|
||||
});
|
||||
|
||||
it("collectOggPackets breaks when fewer than 27 bytes remain after advancing pos", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a valid page + 10 bytes of trailing garbage (< 27 bytes remaining after page).
|
||||
// After processing the page: pos advances past the page data.
|
||||
// while (pos < buf.length) is true (10 bytes remain), but
|
||||
// if (pos + 27 > buf.length) break fires (10 < 27) → covers BRDA:57 true branch.
|
||||
const commentData = buildVorbisCommentData(["TITLE=ShortTrail"]);
|
||||
const commentPacket = [
|
||||
0x03,
|
||||
0x76,
|
||||
0x6f,
|
||||
0x72,
|
||||
0x62,
|
||||
0x69,
|
||||
0x73,
|
||||
...commentData,
|
||||
];
|
||||
const page1 = [
|
||||
0x4f,
|
||||
0x67,
|
||||
0x67,
|
||||
0x53,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
1,
|
||||
commentPacket.length,
|
||||
...commentPacket,
|
||||
];
|
||||
// Append only 10 bytes of garbage (< 27) to trigger the inner pos+27 check
|
||||
const trailing = new Array(10).fill(0xde);
|
||||
const buf = new Uint8Array([...page1, ...trailing]);
|
||||
const track = parseOgg(buf);
|
||||
expect(track.format).toBe("ogg");
|
||||
expect(track.title).toBe("ShortTrail");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { Track } from "../../types/metadata.types.js";
|
||||
import { parseVorbisCommentBlock } from "../flac-vorbis/flac-vorbis.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ogg page/packet walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Ogg page header layout:
|
||||
* "OggS" (4) + version (1) + header_type (1) + granule_pos (8) +
|
||||
* serial (4) + page_seq (4) + checksum (4) + page_segments (1) +
|
||||
* segment_table (page_segments bytes) + data
|
||||
*/
|
||||
function readOggPage(
|
||||
buf: Uint8Array,
|
||||
pos: number
|
||||
): { data: Uint8Array; nextPageOffset: number } | null {
|
||||
if (pos + 27 > buf.length) return null;
|
||||
const numSegs = buf[pos + 26] as number;
|
||||
if (pos + 27 + numSegs > buf.length) return null;
|
||||
|
||||
let dataLen = 0;
|
||||
for (let i = 0; i < numSegs; i++) {
|
||||
dataLen += buf[pos + 27 + i] as number;
|
||||
}
|
||||
|
||||
const dataStart = pos + 27 + numSegs;
|
||||
if (dataStart + dataLen > buf.length) return null;
|
||||
|
||||
return {
|
||||
data: buf.subarray(dataStart, dataStart + dataLen),
|
||||
nextPageOffset: dataStart + dataLen,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect all packets from an Ogg bitstream (logical bitstream reconstruction).
|
||||
* A packet spans one or more segments; a segment of length 255 means the packet
|
||||
* continues on the next segment. Segment < 255 ends the packet.
|
||||
*/
|
||||
function collectOggPackets(buf: Uint8Array, maxPackets: number): Uint8Array[] {
|
||||
const packets: Uint8Array[] = [];
|
||||
let pos = 0;
|
||||
|
||||
while (pos < buf.length && packets.length < maxPackets) {
|
||||
if (pos + 27 > buf.length) break;
|
||||
if (
|
||||
buf[pos] !== 0x4f ||
|
||||
buf[pos + 1] !== 0x67 ||
|
||||
buf[pos + 2] !== 0x67 ||
|
||||
buf[pos + 3] !== 0x53
|
||||
) {
|
||||
break;
|
||||
}
|
||||
|
||||
// pos + 27 <= buf.length is guaranteed by the check above
|
||||
const numSegs = buf[pos + 26] as number;
|
||||
if (pos + 27 + numSegs > buf.length) break;
|
||||
|
||||
const segTable: number[] = [];
|
||||
for (let i = 0; i < numSegs; i++) {
|
||||
// i < numSegs and pos + 27 + numSegs <= buf.length guarantees this index is valid
|
||||
segTable.push(buf[pos + 27 + i] as number);
|
||||
}
|
||||
|
||||
let dataPos = pos + 27 + numSegs;
|
||||
let packetBytes: number[] = [];
|
||||
|
||||
for (const segLen of segTable) {
|
||||
for (let i = 0; i < segLen; i++) {
|
||||
packetBytes.push(buf[dataPos + i] as number);
|
||||
}
|
||||
dataPos += segLen;
|
||||
if (segLen < 255) {
|
||||
// End of packet
|
||||
packets.push(new Uint8Array(packetBytes));
|
||||
packetBytes = [];
|
||||
if (packets.length >= maxPackets) break;
|
||||
}
|
||||
}
|
||||
|
||||
pos = dataPos;
|
||||
}
|
||||
|
||||
return packets;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse Ogg Vorbis metadata from an Ogg container.
|
||||
*
|
||||
* The Vorbis comment header is the second logical packet in the stream.
|
||||
* It starts with the byte sequence 0x03 + "vorbis" (7 bytes total).
|
||||
*
|
||||
* Returns a normalised `Track`. Returns an empty object if the buffer
|
||||
* does not start with "OggS" or no Vorbis comment packet is found.
|
||||
*/
|
||||
export function parseOgg(buffer: Uint8Array): Track {
|
||||
if (buffer.length < 4) return {};
|
||||
if (
|
||||
buffer[0] !== 0x4f ||
|
||||
buffer[1] !== 0x67 ||
|
||||
buffer[2] !== 0x67 ||
|
||||
buffer[3] !== 0x53
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Collect first 3 packets: ident, comment, setup
|
||||
const packets = collectOggPackets(buffer, 3);
|
||||
|
||||
for (const packet of packets) {
|
||||
// Vorbis comment packet starts with 0x03 "vorbis"
|
||||
if (
|
||||
packet.length > 7 &&
|
||||
packet[0] === 0x03 &&
|
||||
packet[1] === 0x76 && // v
|
||||
packet[2] === 0x6f && // o
|
||||
packet[3] === 0x72 && // r
|
||||
packet[4] === 0x62 && // b
|
||||
packet[5] === 0x69 && // i
|
||||
packet[6] === 0x73 // s
|
||||
) {
|
||||
const commentData = packet.subarray(7);
|
||||
const track = parseVorbisCommentBlock(commentData);
|
||||
track.format = "ogg";
|
||||
|
||||
// Try to read sample rate from ident packet (packet[0])
|
||||
const identPacket = packets[0];
|
||||
if (
|
||||
identPacket &&
|
||||
identPacket.length >= 30 &&
|
||||
identPacket[0] === 0x01
|
||||
) {
|
||||
// Vorbis ident: type(1) + "vorbis"(6) + version(4) + channels(1) + sampleRate(4LE)
|
||||
// identPacket.length >= 30 is guaranteed by the condition above
|
||||
const sampleRate =
|
||||
(identPacket[12] as number) |
|
||||
((identPacket[13] as number) << 8) |
|
||||
((identPacket[14] as number) << 16) |
|
||||
((identPacket[15] as number) << 24);
|
||||
const channels = identPacket[11] as number;
|
||||
if (sampleRate > 0) track.sampleRate = sampleRate;
|
||||
if (channels > 0) track.channels = channels;
|
||||
}
|
||||
|
||||
return track;
|
||||
}
|
||||
}
|
||||
|
||||
// Try a simpler scan for the comment packet anywhere in the first page
|
||||
const page = readOggPage(buffer, 0);
|
||||
if (page) {
|
||||
const data = page.data;
|
||||
for (let i = 0; i + 7 < data.length; i++) {
|
||||
if (
|
||||
data[i] === 0x03 &&
|
||||
data[i + 1] === 0x76 &&
|
||||
data[i + 2] === 0x6f &&
|
||||
data[i + 3] === 0x72 &&
|
||||
data[i + 4] === 0x62 &&
|
||||
data[i + 5] === 0x69 &&
|
||||
data[i + 6] === 0x73
|
||||
) {
|
||||
const commentData = data.subarray(i + 7);
|
||||
const track = parseVorbisCommentBlock(commentData);
|
||||
track.format = "ogg";
|
||||
return track;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { format: "ogg" };
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { parseWav } from "./wav-riff";
|
||||
@@ -0,0 +1,65 @@
|
||||
# wav-riff
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Parses WAV (RIFF WAVE) audio files, extracting both audio stream properties from the `fmt` chunk and text metadata from the `LIST INFO` sub-chunk. WAV files do not have a rich tag format like ID3v2 or Vorbis comments — they rely on the RIFF INFO chunk standard, which provides a small set of fixed-purpose fields sufficient for basic library management.
|
||||
|
||||
The parser also reads the `data` chunk size to estimate duration when combined with the sample rate from `fmt`.
|
||||
|
||||
## Features
|
||||
|
||||
- Verifies "RIFF" + "WAVE" magic before parsing
|
||||
- Reads `fmt` chunk: channel count, sample rate, byte rate (→ bitrate estimation)
|
||||
- Reads `data` chunk size for duration estimation (assumes 16-bit PCM)
|
||||
- Finds `LIST INFO` sub-chunks and maps them to `Track` fields:
|
||||
- `INAM` → title, `IART` → artist, `IPRD` → album, `IGNR` → genre, `ICMT` → comment, `ICRD` → year
|
||||
- Handles RIFF chunk padding (chunks padded to even byte boundaries)
|
||||
- Strips trailing null bytes and whitespace from INFO strings
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import { parseWav } from "@suaveplan/audio-metadata/parsers/wav-riff";
|
||||
|
||||
const track = parseWav(wavBuffer);
|
||||
console.log(track.title); // "Sample Track"
|
||||
console.log(track.sampleRate); // 44100
|
||||
console.log(track.channels); // 2
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import { parseWav } from "@suaveplan/audio-metadata/parsers/wav-riff";
|
||||
|
||||
const track = parseWav(wavBuffer);
|
||||
if (track.bitrate) {
|
||||
console.log(`Bitrate: ${track.bitrate} kbps`);
|
||||
}
|
||||
if (track.duration) {
|
||||
const mins = Math.floor(track.duration / 60);
|
||||
const secs = Math.floor(track.duration % 60);
|
||||
console.log(`Duration: ${mins}:${secs.toString().padStart(2, "0")}`);
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `parseWav(buffer)`
|
||||
|
||||
```ts
|
||||
function parseWav(buffer: Uint8Array): Track
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `buffer` | `Uint8Array` | Full WAV file buffer |
|
||||
|
||||
**Returns:** `Track` object with `format: "wav"`. Returns `{}` if the buffer does not start with "RIFF" + "WAVE".
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
RIFF uses little-endian byte ordering throughout, unlike the big-endian conventions in AIFF and MP4. Chunk sizes in RIFF do not include the 8-byte header (4-byte ID + 4-byte size), so the data starts at `offset + 8`. Chunks are always padded to even byte boundaries — the walker adds `size % 2` when advancing to the next chunk. Duration estimation assumes 16-bit PCM (`bytesPerSample = 2`) because the `fmt` chunk is needed to determine actual bits-per-sample, and reading it correctly requires parsing the complete PCM/non-PCM format discriminator. For compressed WAV formats the duration estimate will be inaccurate, but the sample rate and channel count will still be correct.
|
||||
@@ -0,0 +1,355 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parseWav } from "./wav-riff.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function uint32LE(n: number): number[] {
|
||||
return [n & 0xff, (n >> 8) & 0xff, (n >> 16) & 0xff, (n >> 24) & 0xff];
|
||||
}
|
||||
|
||||
function uint16LE(n: number): number[] {
|
||||
return [n & 0xff, (n >> 8) & 0xff];
|
||||
}
|
||||
|
||||
function riffChunk(id: string, data: number[]): number[] {
|
||||
const idBytes = Array.from(new TextEncoder().encode(id));
|
||||
const paddedSize = data.length + (data.length % 2); // pad to even
|
||||
const padded = [...data];
|
||||
if (data.length % 2 !== 0) padded.push(0);
|
||||
return [
|
||||
...idBytes.slice(0, 4),
|
||||
...uint32LE(data.length),
|
||||
...padded.slice(0, paddedSize),
|
||||
];
|
||||
}
|
||||
|
||||
function infoChunk(id: string, value: string): number[] {
|
||||
const valBytes = [...Array.from(new TextEncoder().encode(value)), 0x00]; // null terminated
|
||||
return riffChunk(id, valBytes);
|
||||
}
|
||||
|
||||
function listInfoChunk(infoChunks: number[]): number[] {
|
||||
const typeBytes = Array.from(new TextEncoder().encode("INFO"));
|
||||
return riffChunk("LIST", [...typeBytes, ...infoChunks]);
|
||||
}
|
||||
|
||||
function fmtChunk(channels: number, sampleRate: number): number[] {
|
||||
const byteRate = sampleRate * channels * 2; // 16-bit PCM
|
||||
const blockAlign = channels * 2;
|
||||
return riffChunk("fmt ", [
|
||||
...uint16LE(1), // PCM format
|
||||
...uint16LE(channels),
|
||||
...uint32LE(sampleRate),
|
||||
...uint32LE(byteRate),
|
||||
...uint16LE(blockAlign),
|
||||
...uint16LE(16), // bits per sample
|
||||
]);
|
||||
}
|
||||
|
||||
function buildWavFile(extraChunks: number[][]): Uint8Array {
|
||||
const waveBytes = Array.from(new TextEncoder().encode("WAVE"));
|
||||
const allChunks = extraChunks.flat();
|
||||
const riffSize = 4 + allChunks.length; // "WAVE" + chunks
|
||||
const header = [
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46, // "RIFF"
|
||||
...uint32LE(riffSize),
|
||||
...waveBytes,
|
||||
...allChunks,
|
||||
];
|
||||
return new Uint8Array(header);
|
||||
}
|
||||
|
||||
describe("wav-riff parser", () => {
|
||||
it("parses title from LIST INFO INAM", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([
|
||||
fmtChunk(2, 44100),
|
||||
listInfoChunk(infoChunk("INAM", "Test WAV Title")),
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.title).toBe("Test WAV Title");
|
||||
expect(track.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("parses artist from IART", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([
|
||||
listInfoChunk(infoChunk("IART", "WAV Artist")),
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.artist).toBe("WAV Artist");
|
||||
});
|
||||
|
||||
it("parses album from IPRD", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([
|
||||
listInfoChunk(infoChunk("IPRD", "WAV Album")),
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.album).toBe("WAV Album");
|
||||
});
|
||||
|
||||
it("parses year from ICRD", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([
|
||||
listInfoChunk(infoChunk("ICRD", "2020-01-15")),
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.year).toBe(2020);
|
||||
});
|
||||
|
||||
it("parses genre from IGNR", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([
|
||||
listInfoChunk(infoChunk("IGNR", "Classical")),
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.genre).toBe("Classical");
|
||||
});
|
||||
|
||||
it("parses comment from ICMT", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([
|
||||
listInfoChunk(infoChunk("ICMT", "Nice WAV")),
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.comment).toBe("Nice WAV");
|
||||
});
|
||||
|
||||
it("reads sample rate and channels from fmt chunk", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const buf = buildWavFile([fmtChunk(2, 44100)]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.sampleRate).toBe(44100);
|
||||
expect(track.channels).toBe(2);
|
||||
});
|
||||
|
||||
it("returns empty object for non-WAV buffer", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
parseWav(
|
||||
new Uint8Array([
|
||||
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09,
|
||||
0x0a, 0x0b,
|
||||
])
|
||||
)
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for short buffer", { timeout: 5000 }, () => {
|
||||
expect(parseWav(new Uint8Array([0x52, 0x49, 0x46, 0x46]))).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object for RIFF without WAVE", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
0x04,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x41,
|
||||
0x56,
|
||||
0x49,
|
||||
0x20, // "AVI " not WAVE
|
||||
]);
|
||||
expect(parseWav(buf)).toEqual({});
|
||||
});
|
||||
|
||||
it("stores rawTags", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([listInfoChunk(infoChunk("INAM", "Tagged"))]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.rawTags?.["INAM"]).toBe("Tagged");
|
||||
});
|
||||
|
||||
it("calculates duration from data chunk and fmt", { timeout: 5000 }, () => {
|
||||
// 44100 samples at 16-bit stereo = 44100 * 4 bytes/frame
|
||||
const numSamples = 44100; // 1 second
|
||||
const numBytes = numSamples * 2 * 2; // 16-bit stereo
|
||||
const dataChunk = riffChunk("data", new Array(numBytes).fill(0));
|
||||
const buf = buildWavFile([fmtChunk(2, 44100), dataChunk]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.sampleRate).toBe(44100);
|
||||
expect(track.channels).toBe(2);
|
||||
expect(track.duration).toBeCloseTo(1.0, 1);
|
||||
});
|
||||
|
||||
it("computes bitrate from fmt byteRate", { timeout: 5000 }, () => {
|
||||
const buf = buildWavFile([fmtChunk(2, 44100)]);
|
||||
const track = parseWav(buf);
|
||||
// byteRate = 44100 * 2 * 2 = 176400, bitrate = round(176400 * 8 / 1000) = 1411
|
||||
expect(track.bitrate).toBe(1411);
|
||||
});
|
||||
|
||||
it("handles truncated fmt chunk gracefully (size < 16 — skips content)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a RIFF/WAVE with a fmt chunk that has size < 16
|
||||
// This triggers the FALSE branch of `if (chunk.size >= 16)`
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
// fmt chunk with only 8 bytes of data (size=8 < 16)
|
||||
const fmtData = [0x01, 0x00, 0x02, 0x00, 0x44, 0xac, 0x00, 0x00];
|
||||
const fmtChunkBytes = [
|
||||
0x66,
|
||||
0x6d,
|
||||
0x74,
|
||||
0x20, // "fmt "
|
||||
...uint32LE(8), // size = 8 (less than 16)
|
||||
...fmtData,
|
||||
];
|
||||
const riffSize = 4 + fmtChunkBytes.length;
|
||||
const buf = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
...uint32LE(riffSize),
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45,
|
||||
...fmtChunkBytes,
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.format).toBe("wav");
|
||||
// channels/sampleRate not set (fmt too small)
|
||||
expect(track.channels).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles fmt chunk where channel bytes are at buffer boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a RIFF/WAVE with a fmt chunk that claims size=16 but only has 2 bytes of data.
|
||||
// This triggers the ??(0) fallbacks for channel/sampleRate bytes beyond buffer
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
// Only 2 bytes of real data after the 8-byte id+size header
|
||||
const fmtChunkBytes = [
|
||||
0x66,
|
||||
0x6d,
|
||||
0x74,
|
||||
0x20, // "fmt "
|
||||
...uint32LE(16), // claims 16 bytes
|
||||
0x01,
|
||||
0x00, // only 2 bytes — everything else out of bounds
|
||||
];
|
||||
const riffSize = 4 + fmtChunkBytes.length;
|
||||
const buf = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
...uint32LE(riffSize),
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45,
|
||||
...fmtChunkBytes,
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("ignores non-INFO LIST chunk", { timeout: 5000 }, () => {
|
||||
// Build a LIST chunk with type "adtl" (not "INFO")
|
||||
const adtlTypeBytes = Array.from(new TextEncoder().encode("adtl"));
|
||||
const listData = [...adtlTypeBytes, 0x00, 0x00, 0x00, 0x00];
|
||||
const listChunk = riffChunk("LIST", listData);
|
||||
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
|
||||
const track = parseWav(buf);
|
||||
// No tags — title/artist should be undefined
|
||||
expect(track.title).toBeUndefined();
|
||||
expect(track.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("skips empty INFO values", { timeout: 5000 }, () => {
|
||||
// INAM with only null bytes — after trim should be empty
|
||||
const emptyNameChunk = riffChunk("INAM", [0x00]);
|
||||
const listChunk = listInfoChunk(emptyNameChunk);
|
||||
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
|
||||
const track = parseWav(buf);
|
||||
// empty value should not be assigned
|
||||
expect(track.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles data chunk without prior fmt chunk (no duration)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// data chunk before fmt chunk — duration calc requires sampleRate to be set first
|
||||
const numBytes = 1000;
|
||||
const dataChunk = riffChunk("data", new Array(numBytes).fill(0));
|
||||
const buf = buildWavFile([dataChunk, fmtChunk(2, 44100)]);
|
||||
const track = parseWav(buf);
|
||||
// fmt comes after data so when data is processed sampleRate may not be set
|
||||
// (depends on iteration order — this tests the branch where sampleRate is not yet known)
|
||||
expect(track.format).toBe("wav");
|
||||
});
|
||||
|
||||
it("skips LIST chunk when data is truncated at boundary", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Build a RIFF header that claims there's a LIST chunk but only 3 bytes of data (< 4)
|
||||
// This exercises the `chunk.dataOffset + 4 > buffer.length` check
|
||||
const uint32LE = (n: number) => [
|
||||
n & 0xff,
|
||||
(n >> 8) & 0xff,
|
||||
(n >> 16) & 0xff,
|
||||
(n >> 24) & 0xff,
|
||||
];
|
||||
// LIST chunk: id="LIST" + size=3 (meaning 3 bytes of data)
|
||||
const listChunk = [
|
||||
0x4c,
|
||||
0x49,
|
||||
0x53,
|
||||
0x54, // "LIST"
|
||||
...uint32LE(3), // size=3 (fewer than 4 bytes needed for type)
|
||||
0x49,
|
||||
0x4e,
|
||||
0x46, // 3 bytes of data (incomplete type)
|
||||
];
|
||||
const riffSize = 4 + listChunk.length;
|
||||
const buf = new Uint8Array([
|
||||
0x52,
|
||||
0x49,
|
||||
0x46,
|
||||
0x46,
|
||||
...uint32LE(riffSize),
|
||||
0x57,
|
||||
0x41,
|
||||
0x56,
|
||||
0x45,
|
||||
...listChunk,
|
||||
]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.format).toBe("wav");
|
||||
expect(track.title).toBeUndefined();
|
||||
});
|
||||
|
||||
it("skips empty ICRD date string", { timeout: 5000 }, () => {
|
||||
// ICRD with only null bytes — after trim should be empty, year should not be set
|
||||
const emptyIcrdChunk = riffChunk("ICRD", [0x00]);
|
||||
const listChunk = listInfoChunk(emptyIcrdChunk);
|
||||
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.year).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores non-numeric ICRD year", { timeout: 5000 }, () => {
|
||||
// ICRD with non-numeric date — parseInt returns NaN, year should not be set
|
||||
const nonNumericIcrd = infoChunk("ICRD", "abcd-01-01");
|
||||
const listChunk = listInfoChunk(nonNumericIcrd);
|
||||
const buf = buildWavFile([fmtChunk(2, 44100), listChunk]);
|
||||
const track = parseWav(buf);
|
||||
expect(track.year).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Track } from "../../types/metadata.types.js";
|
||||
import { readString, readUint32LE } from "../../utils/buffer-utils.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RIFF chunk walker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface Chunk {
|
||||
id: string;
|
||||
size: number;
|
||||
dataOffset: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate RIFF sub-chunks within [start, end).
|
||||
* Each chunk: 4-byte ASCII id + 4-byte little-endian size + data.
|
||||
* Chunks are padded to even byte boundaries.
|
||||
*/
|
||||
function* iterChunks(
|
||||
buf: Uint8Array,
|
||||
start: number,
|
||||
end: number
|
||||
): Generator<Chunk> {
|
||||
let pos = start;
|
||||
while (pos + 8 <= end) {
|
||||
const id = readString(buf, pos, 4, "latin1");
|
||||
const size = readUint32LE(buf, pos + 4);
|
||||
yield { id, size, dataOffset: pos + 8 };
|
||||
pos += 8 + size + (size % 2); // pad to even
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// INFO chunk tag mappings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const INFO_MAP: Record<
|
||||
string,
|
||||
keyof Pick<Track, "title" | "artist" | "album" | "genre" | "comment">
|
||||
> = {
|
||||
INAM: "title",
|
||||
IART: "artist",
|
||||
IPRD: "album",
|
||||
IGNR: "genre",
|
||||
ICMT: "comment",
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Parse WAV/RIFF metadata from a LIST INFO chunk.
|
||||
*
|
||||
* WAV files use a RIFF container. Metadata is stored in a LIST chunk whose
|
||||
* first four data bytes are "INFO". Inside, each sub-chunk maps to a tag:
|
||||
* INAM → title, IART → artist, IPRD → album, ICRD → year, IGNR → genre,
|
||||
* ICMT → comment.
|
||||
*
|
||||
* Returns a normalised `Track`. Returns an empty object if the buffer
|
||||
* does not start with "RIFF" + "WAVE".
|
||||
*/
|
||||
export function parseWav(buffer: Uint8Array): Track {
|
||||
if (buffer.length < 12) return {};
|
||||
if (
|
||||
buffer[0] !== 0x52 || // R
|
||||
buffer[1] !== 0x49 || // I
|
||||
buffer[2] !== 0x46 || // F
|
||||
buffer[3] !== 0x46 // F
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
if (
|
||||
buffer[8] !== 0x57 || // W
|
||||
buffer[9] !== 0x41 || // A
|
||||
buffer[10] !== 0x56 || // V
|
||||
buffer[11] !== 0x45 // E
|
||||
) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const riffSize = readUint32LE(buffer, 4);
|
||||
const end = Math.min(12 + riffSize - 4, buffer.length); // -4 because riffSize includes "WAVE"
|
||||
|
||||
const raw: Record<string, string | string[]> = {};
|
||||
const track: Track = { format: "wav" };
|
||||
|
||||
// Also extract fmt chunk for audio properties
|
||||
for (const chunk of iterChunks(buffer, 12, end)) {
|
||||
if (chunk.id === "fmt ") {
|
||||
// PCM: audio format(2LE), channels(2LE), sampleRate(4LE), byteRate(4LE), blockAlign(2LE), bitsPerSample(2LE)
|
||||
if (chunk.size >= 16) {
|
||||
const channels =
|
||||
(buffer[chunk.dataOffset + 2] ?? 0) |
|
||||
((buffer[chunk.dataOffset + 3] ?? 0) << 8);
|
||||
const sampleRate = readUint32LE(buffer, chunk.dataOffset + 4);
|
||||
const byteRate = readUint32LE(buffer, chunk.dataOffset + 8);
|
||||
if (channels > 0) track.channels = channels;
|
||||
if (sampleRate > 0) track.sampleRate = sampleRate;
|
||||
if (byteRate > 0)
|
||||
track.bitrate = Math.round((byteRate * 8) / 1000);
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.id === "data") {
|
||||
if (track.sampleRate && track.channels) {
|
||||
const bitsPerSample = 16; // Assume 16-bit PCM if not otherwise known
|
||||
const bytesPerSample = bitsPerSample / 8;
|
||||
// track.channels is truthy here (checked in the if above)
|
||||
const totalSamples =
|
||||
chunk.size / (bytesPerSample * (track.channels as number));
|
||||
track.duration = totalSamples / track.sampleRate;
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.id !== "LIST") continue;
|
||||
if (chunk.dataOffset + 4 > buffer.length) continue;
|
||||
|
||||
const listType = readString(buffer, chunk.dataOffset, 4, "latin1");
|
||||
if (listType !== "INFO") continue;
|
||||
|
||||
for (const infoChunk of iterChunks(
|
||||
buffer,
|
||||
chunk.dataOffset + 4,
|
||||
chunk.dataOffset + chunk.size
|
||||
)) {
|
||||
const fieldKey = INFO_MAP[infoChunk.id];
|
||||
if (fieldKey) {
|
||||
const val = readString(
|
||||
buffer,
|
||||
infoChunk.dataOffset,
|
||||
infoChunk.size,
|
||||
"latin1"
|
||||
)
|
||||
.replace(/\0+$/, "")
|
||||
.trim();
|
||||
if (val) {
|
||||
track[fieldKey] = val;
|
||||
raw[infoChunk.id] = val;
|
||||
}
|
||||
}
|
||||
|
||||
if (infoChunk.id === "ICRD") {
|
||||
const dateStr = readString(
|
||||
buffer,
|
||||
infoChunk.dataOffset,
|
||||
infoChunk.size,
|
||||
"latin1"
|
||||
)
|
||||
.replace(/\0+$/, "")
|
||||
.trim();
|
||||
if (dateStr) {
|
||||
raw["ICRD"] = dateStr;
|
||||
const y = Number.parseInt(dateStr.substring(0, 4), 10);
|
||||
if (!Number.isNaN(y)) track.year = y;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
track.rawTags = raw;
|
||||
return track;
|
||||
}
|
||||
7
packages/media/audio/audio-metadata/src/types/index.ts
Normal file
7
packages/media/audio/audio-metadata/src/types/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export type {
|
||||
AudioFormat,
|
||||
AudioMetadata,
|
||||
CoverArt,
|
||||
MetadataExtractionOptions,
|
||||
Track,
|
||||
} from "./metadata.types";
|
||||
127
packages/media/audio/audio-metadata/src/types/metadata.types.md
Normal file
127
packages/media/audio/audio-metadata/src/types/metadata.types.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Metadata Types
|
||||
|
||||
## Purpose
|
||||
|
||||
Shared TypeScript type definitions for the `@suaveplan/audio-metadata` package. These interfaces describe the normalised output shape returned by every format-specific parser and the `MetadataExtractor` facade, as well as the configuration surface for controlling extraction behaviour. Centralising them in a dedicated types file prevents circular imports between parser modules and provides a single authoritative place to extend the public API contract.
|
||||
|
||||
## Features
|
||||
|
||||
- `AudioFormat` — discriminated union of the seven supported container/tag formats plus `"unknown"`
|
||||
- `CoverArt` — embedded artwork record with raw bytes, MIME type, and optional description
|
||||
- `Track` — normalised audio tag record covering title, artist, album, disc/track numbering, genre, lyrics, cover art, and stream-level properties (duration, bitrate, sample rate, channels)
|
||||
- `AudioMetadata` — top-level result envelope returned by `extractMetadata`, combining format, track, and stream properties
|
||||
- `MetadataExtractionOptions` — caller-supplied options to skip covers or lyrics and cap cover image size
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import type { AudioMetadata, Track } from '@suaveplan/audio-metadata';
|
||||
|
||||
// Result from extractMetadata()
|
||||
const meta: AudioMetadata = {
|
||||
format: 'id3v2',
|
||||
track: {
|
||||
title: 'Bohemian Rhapsody',
|
||||
artist: 'Queen',
|
||||
album: 'A Night at the Opera',
|
||||
year: 1975,
|
||||
trackNumber: 11,
|
||||
},
|
||||
};
|
||||
|
||||
const track: Track = meta.track;
|
||||
console.log(track.title); // 'Bohemian Rhapsody'
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import type { CoverArt, MetadataExtractionOptions, Track } from '@suaveplan/audio-metadata';
|
||||
import { extractMetadata } from '@suaveplan/audio-metadata';
|
||||
|
||||
const options: MetadataExtractionOptions = {
|
||||
skipCovers: false,
|
||||
maxCoverSize: 2 * 1024 * 1024, // 2 MiB
|
||||
skipLyrics: true,
|
||||
};
|
||||
|
||||
const buffer = await fs.readFile('track.mp3');
|
||||
const meta = await extractMetadata(new Uint8Array(buffer), options);
|
||||
|
||||
const cover: CoverArt | undefined = meta.track.cover;
|
||||
if (cover) {
|
||||
console.log(cover.mimeType); // e.g. "image/jpeg"
|
||||
console.log(cover.data.length); // byte length
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `AudioFormat`
|
||||
|
||||
```ts
|
||||
type AudioFormat =
|
||||
| 'id3v1' | 'id3v2' | 'mp4' | 'flac'
|
||||
| 'ogg' | 'wav' | 'aiff' | 'unknown';
|
||||
```
|
||||
|
||||
String union identifying which tag format was detected and parsed.
|
||||
|
||||
### `CoverArt`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `data` | `Uint8Array` | Raw image bytes |
|
||||
| `mimeType` | `string` | MIME type, e.g. `"image/jpeg"` |
|
||||
| `description` | `string?` | Optional description from the tag |
|
||||
|
||||
### `Track`
|
||||
|
||||
All fields are optional — parsers populate what the container format supports.
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `title` | `string?` | Track title |
|
||||
| `artist` | `string?` | Primary artist |
|
||||
| `album` | `string?` | Album title |
|
||||
| `albumArtist` | `string?` | Album-level artist |
|
||||
| `year` | `number?` | Release year |
|
||||
| `trackNumber` | `number?` | Position in album |
|
||||
| `totalTracks` | `number?` | Total tracks on album |
|
||||
| `discNumber` | `number?` | Disc number |
|
||||
| `totalDiscs` | `number?` | Total discs |
|
||||
| `genre` | `string?` | Genre string |
|
||||
| `comment` | `string?` | Free-text comment |
|
||||
| `lyrics` | `string?` | Embedded lyrics |
|
||||
| `cover` | `CoverArt?` | Embedded cover art |
|
||||
| `duration` | `number?` | Duration in seconds |
|
||||
| `bitrate` | `number?` | Bitrate in kbps |
|
||||
| `sampleRate` | `number?` | Sample rate in Hz |
|
||||
| `channels` | `number?` | Audio channel count |
|
||||
| `format` | `AudioFormat?` | Source tag format |
|
||||
| `rawTags` | `Record<string, string \| string[]>?` | Raw pre-normalisation key→value pairs |
|
||||
|
||||
### `AudioMetadata`
|
||||
|
||||
| Field | Type | Description |
|
||||
|---|---|---|
|
||||
| `format` | `AudioFormat` | Detected container/tag format |
|
||||
| `track` | `Track` | Normalised tag data |
|
||||
| `duration` | `number?` | Stream duration in seconds |
|
||||
| `bitrate` | `number?` | Stream bitrate in kbps |
|
||||
|
||||
### `MetadataExtractionOptions`
|
||||
|
||||
| Field | Type | Default | Description |
|
||||
|---|---|---|---|
|
||||
| `skipCovers` | `boolean?` | `false` | Skip decoding embedded cover art |
|
||||
| `maxCoverSize` | `number?` | `5 242 880` | Maximum cover image bytes (5 MiB) |
|
||||
| `skipLyrics` | `boolean?` | `false` | Skip decoding embedded lyrics |
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
All interfaces are exported from `src/index.ts` and available under the package root import. The `Track` interface uses `undefined`-optional fields rather than `null` to stay consistent with TypeScript strict-mode patterns across the project; callers should use optional chaining (`track.title?.trim()`) rather than null checks.
|
||||
|
||||
The `rawTags` field preserves the original, pre-normalisation key→value map so callers with format-specific knowledge can access non-standard tags that the normaliser does not promote to first-class `Track` fields. Values are typed as `string | string[]` because some formats (ID3v2 TXXX frames, Vorbis comments) allow repeated tags for the same key.
|
||||
|
||||
The `types` file contains no runtime code and therefore has no test file, but it is included in `docs:check` coverage because it forms part of the package's documented public API surface.
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Core types for the @suaveplan/audio-metadata package.
|
||||
*/
|
||||
|
||||
/** Supported audio format identifiers. */
|
||||
export type AudioFormat =
|
||||
| "id3v1"
|
||||
| "id3v2"
|
||||
| "mp4"
|
||||
| "flac"
|
||||
| "ogg"
|
||||
| "wav"
|
||||
| "aiff"
|
||||
| "unknown";
|
||||
|
||||
/** Embedded cover art extracted from audio metadata. */
|
||||
export interface CoverArt {
|
||||
/** Raw image bytes. */
|
||||
data: Uint8Array;
|
||||
/** MIME type of the image, e.g. "image/jpeg" or "image/png". */
|
||||
mimeType: string;
|
||||
/** Optional description string from the tag. */
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalised track record returned by all parsers and the extractor.
|
||||
* All fields are optional — parsers populate what the format supports.
|
||||
*/
|
||||
export interface Track {
|
||||
title?: string;
|
||||
artist?: string;
|
||||
album?: string;
|
||||
albumArtist?: string;
|
||||
year?: number;
|
||||
trackNumber?: number;
|
||||
totalTracks?: number;
|
||||
discNumber?: number;
|
||||
totalDiscs?: number;
|
||||
genre?: string;
|
||||
comment?: string;
|
||||
lyrics?: string;
|
||||
cover?: CoverArt;
|
||||
/** Duration in seconds. */
|
||||
duration?: number;
|
||||
/** Bitrate in kbps. */
|
||||
bitrate?: number;
|
||||
/** Sample rate in Hz. */
|
||||
sampleRate?: number;
|
||||
/** Number of audio channels. */
|
||||
channels?: number;
|
||||
/** The format this track's tags were read from. */
|
||||
format?: AudioFormat;
|
||||
/** Raw key→value tag pairs before normalisation. */
|
||||
rawTags?: Record<string, string | string[]>;
|
||||
}
|
||||
|
||||
/** Top-level result returned by `extractMetadata`. */
|
||||
export interface AudioMetadata {
|
||||
format: AudioFormat;
|
||||
track: Track;
|
||||
duration?: number;
|
||||
bitrate?: number;
|
||||
}
|
||||
|
||||
/** Options that control what the extractor reads. */
|
||||
export interface MetadataExtractionOptions {
|
||||
/** Skip decoding embedded cover images. Default: false. */
|
||||
skipCovers?: boolean;
|
||||
/** Maximum number of bytes to accept for a cover image. Default: 5 242 880 (5 MiB). */
|
||||
maxCoverSize?: number;
|
||||
/** Skip decoding embedded lyrics. Default: false. */
|
||||
skipLyrics?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
# buffer-utils
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
## Purpose
|
||||
|
||||
Provides a compact set of pure binary-reading helpers that operate directly on `Uint8Array` values. These utilities abstract away the bit-shifting and byte-ordering arithmetic that every audio-format parser needs, reducing the risk of off-by-one errors and making parser code easier to read and test in isolation.
|
||||
|
||||
All helpers are environment-agnostic — they use only the `Uint8Array` and `TextDecoder` web-standard APIs, so they work identically in Node.js 18+, Deno, and modern browsers without any polyfills.
|
||||
|
||||
## Features
|
||||
|
||||
- Unsigned/signed integer readers: `readUint8`, `readUint16BE`, `readUint16LE`, `readUint32BE`, `readUint32LE`, `readInt32BE`
|
||||
- String decoder with `"utf8"` (TextDecoder) and `"latin1"` (char-code loop) encodings
|
||||
- `readSyncsafeInt` for ID3v2 syncsafe 28-bit integers
|
||||
- `concatBuffers` for building test fixtures and merging partial reads
|
||||
- Every function returns a sensible default (`0` / `""`) on out-of-bounds access rather than throwing
|
||||
|
||||
## Usage: Basic Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
readUint32BE,
|
||||
readString,
|
||||
} from "@suaveplan/audio-metadata";
|
||||
|
||||
const buf = new Uint8Array([0x00, 0x00, 0x04, 0x00]);
|
||||
const size = readUint32BE(buf, 0); // 1024
|
||||
|
||||
const text = new Uint8Array([0x48, 0x69]);
|
||||
readString(text, 0, 2); // "Hi"
|
||||
```
|
||||
|
||||
## Usage: Advanced Example
|
||||
|
||||
```ts
|
||||
import {
|
||||
readSyncsafeInt,
|
||||
readUint16BE,
|
||||
concatBuffers,
|
||||
} from "@suaveplan/audio-metadata";
|
||||
|
||||
// Decode an ID3v2 tag size from a header
|
||||
const header = new Uint8Array([0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x02, 0x01]);
|
||||
const tagSize = readSyncsafeInt(header, 6); // 257
|
||||
|
||||
// Build a larger test buffer by concatenating parts
|
||||
const part1 = new Uint8Array([0x52, 0x49, 0x46, 0x46]);
|
||||
const part2 = new Uint8Array([0x00, 0x10, 0x00, 0x00]);
|
||||
const combined = concatBuffers(part1, part2); // "RIFF" + size bytes
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
### `readUint8(buf, offset)`
|
||||
|
||||
Returns `buf[offset]` as a number (0–255), or `0` if out of bounds.
|
||||
|
||||
### `readUint16BE(buf, offset)`
|
||||
|
||||
Reads two bytes at `offset` as a big-endian unsigned 16-bit integer.
|
||||
|
||||
### `readUint16LE(buf, offset)`
|
||||
|
||||
Reads two bytes at `offset` as a little-endian unsigned 16-bit integer.
|
||||
|
||||
### `readUint32BE(buf, offset)`
|
||||
|
||||
Reads four bytes at `offset` as a big-endian unsigned 32-bit integer.
|
||||
|
||||
### `readUint32LE(buf, offset)`
|
||||
|
||||
Reads four bytes at `offset` as a little-endian unsigned 32-bit integer.
|
||||
|
||||
### `readInt32BE(buf, offset)`
|
||||
|
||||
Reads four bytes at `offset` as a big-endian signed 32-bit integer. Converts unsigned values ≥ 0x80000000 to their negative two's-complement equivalents.
|
||||
|
||||
### `readString(buf, offset, length, encoding?)`
|
||||
|
||||
Decodes `length` bytes starting at `offset`.
|
||||
|
||||
- `encoding = "utf8"` (default): uses `TextDecoder("utf-8")` for correct multi-byte handling.
|
||||
- `encoding = "latin1"`: maps each byte to its Unicode code point — necessary for ID3v1 and some legacy ID3v2 fields.
|
||||
|
||||
### `readSyncsafeInt(buf, offset)`
|
||||
|
||||
Reads four bytes at `offset` and decodes them as an ID3v2 syncsafe integer (28-bit value, MSB of each byte ignored). Returns a plain `number`.
|
||||
|
||||
### `concatBuffers(a, b)`
|
||||
|
||||
Returns a new `Uint8Array` containing all bytes of `a` followed by all bytes of `b`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `>>> 0` cast after bit-shifting operations ensures the result is treated as an unsigned 32-bit integer in JavaScript, preventing sign-extension surprises with values whose MSB is set. The `readUint32BE` implementation uses multiplication by `0x1000000` instead of `<< 24` to avoid the signed-integer trap that `<< 24` introduces for bytes with the high bit set. Out-of-bounds array reads return `undefined` in JavaScript, which is coerced to `0` via the `?? 0` fallback — this trades silent safety for parser robustness with truncated buffers.
|
||||
@@ -0,0 +1,253 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
concatBuffers,
|
||||
readInt32BE,
|
||||
readString,
|
||||
readSyncsafeInt,
|
||||
readUint8,
|
||||
readUint16BE,
|
||||
readUint16LE,
|
||||
readUint32BE,
|
||||
readUint32LE,
|
||||
} from "./buffer-utils.js";
|
||||
|
||||
describe("buffer-utils", () => {
|
||||
describe("readUint8", () => {
|
||||
it("reads a single byte", { timeout: 5000 }, () => {
|
||||
expect(readUint8(new Uint8Array([0xab, 0xcd]), 0)).toBe(0xab);
|
||||
expect(readUint8(new Uint8Array([0xab, 0xcd]), 1)).toBe(0xcd);
|
||||
});
|
||||
|
||||
it("returns 0 for out-of-bounds", { timeout: 5000 }, () => {
|
||||
expect(readUint8(new Uint8Array([0xff]), 5)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUint16BE", () => {
|
||||
it("reads big-endian uint16", { timeout: 5000 }, () => {
|
||||
expect(readUint16BE(new Uint8Array([0x01, 0x00]), 0)).toBe(256);
|
||||
expect(readUint16BE(new Uint8Array([0xff, 0xff]), 0)).toBe(65535);
|
||||
expect(readUint16BE(new Uint8Array([0x00, 0x01]), 0)).toBe(1);
|
||||
});
|
||||
|
||||
it("reads at offset", { timeout: 5000 }, () => {
|
||||
expect(readUint16BE(new Uint8Array([0x00, 0x01, 0x02]), 1)).toBe(
|
||||
0x0102
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 0 for out-of-bounds offset", { timeout: 5000 }, () => {
|
||||
expect(readUint16BE(new Uint8Array([0xff]), 5)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns partial value when only first byte in bounds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// buf[0]=0x01, buf[1]=undefined→0; result = (0x01<<8)|0 = 256
|
||||
expect(readUint16BE(new Uint8Array([0x01]), 0)).toBe(256);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUint16LE", () => {
|
||||
it("reads little-endian uint16", { timeout: 5000 }, () => {
|
||||
expect(readUint16LE(new Uint8Array([0x01, 0x00]), 0)).toBe(1);
|
||||
expect(readUint16LE(new Uint8Array([0x00, 0x01]), 0)).toBe(256);
|
||||
expect(readUint16LE(new Uint8Array([0xff, 0xff]), 0)).toBe(65535);
|
||||
});
|
||||
|
||||
it("returns 0 for out-of-bounds", { timeout: 5000 }, () => {
|
||||
expect(readUint16LE(new Uint8Array([0x01]), 5)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns partial value when only second byte in bounds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// buf[1]=undefined→0, buf[0]=0x01; result = (0<<8)|0x01 = 1
|
||||
expect(readUint16LE(new Uint8Array([0x01]), 0)).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUint32BE", () => {
|
||||
it("reads big-endian uint32", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
readUint32BE(new Uint8Array([0x00, 0x00, 0x04, 0x00]), 0)
|
||||
).toBe(1024);
|
||||
expect(
|
||||
readUint32BE(new Uint8Array([0x00, 0x00, 0x00, 0x01]), 0)
|
||||
).toBe(1);
|
||||
expect(
|
||||
readUint32BE(new Uint8Array([0x01, 0x00, 0x00, 0x00]), 0)
|
||||
).toBe(0x1000000);
|
||||
});
|
||||
|
||||
it("handles high bit without sign extension", { timeout: 5000 }, () => {
|
||||
const result = readUint32BE(
|
||||
new Uint8Array([0x80, 0x00, 0x00, 0x00]),
|
||||
0
|
||||
);
|
||||
expect(result).toBe(0x80000000);
|
||||
expect(result).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("reads at offset", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
readUint32BE(new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x01]), 1)
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("returns 0 for fully out-of-bounds offset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(readUint32BE(new Uint8Array([0x01, 0x02]), 10)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns partial value when fewer than 4 bytes in bounds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// Only 2 bytes at offset 0 — bytes [2] and [3] are undefined→0
|
||||
// result = (0x01 * 0x1000000) + ((0x02 << 16) | 0 | 0) = 0x1020000
|
||||
expect(readUint32BE(new Uint8Array([0x01, 0x02]), 0)).toBe(
|
||||
0x01020000
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readUint32LE", () => {
|
||||
it("reads little-endian uint32", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
readUint32LE(new Uint8Array([0x01, 0x00, 0x00, 0x00]), 0)
|
||||
).toBe(1);
|
||||
expect(
|
||||
readUint32LE(new Uint8Array([0x00, 0x04, 0x00, 0x00]), 0)
|
||||
).toBe(1024);
|
||||
expect(
|
||||
readUint32LE(new Uint8Array([0xff, 0xff, 0xff, 0xff]), 0)
|
||||
).toBe(4294967295);
|
||||
});
|
||||
|
||||
it("returns 0 for fully out-of-bounds offset", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(readUint32LE(new Uint8Array([0x01, 0x02]), 10)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns partial value when fewer than 4 bytes in bounds", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// buf[0]=0x01, buf[1]=0x02, buf[2]=undefined→0, buf[3]=undefined→0
|
||||
// result = (0 * 0x1000000) + (0<<16) + (0x02<<8) + 0x01 = 0x0201
|
||||
expect(readUint32LE(new Uint8Array([0x01, 0x02]), 0)).toBe(0x0201);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readInt32BE", () => {
|
||||
it("reads positive big-endian int32", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
readInt32BE(new Uint8Array([0x00, 0x00, 0x00, 0x01]), 0)
|
||||
).toBe(1);
|
||||
expect(
|
||||
readInt32BE(new Uint8Array([0x7f, 0xff, 0xff, 0xff]), 0)
|
||||
).toBe(2147483647);
|
||||
});
|
||||
|
||||
it("reads negative big-endian int32", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
readInt32BE(new Uint8Array([0xff, 0xff, 0xff, 0xff]), 0)
|
||||
).toBe(-1);
|
||||
expect(
|
||||
readInt32BE(new Uint8Array([0x80, 0x00, 0x00, 0x00]), 0)
|
||||
).toBe(-2147483648);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readString", () => {
|
||||
it("reads utf8 string", { timeout: 5000 }, () => {
|
||||
const buf = new TextEncoder().encode("Hello");
|
||||
expect(readString(buf, 0, 5)).toBe("Hello");
|
||||
});
|
||||
|
||||
it("reads partial string", { timeout: 5000 }, () => {
|
||||
const buf = new TextEncoder().encode("Hello World");
|
||||
expect(readString(buf, 6, 5)).toBe("World");
|
||||
});
|
||||
|
||||
it("reads latin1 string", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0x41, 0x42, 0xc9]); // AB + latin1 É
|
||||
expect(readString(buf, 0, 2, "latin1")).toBe("AB");
|
||||
});
|
||||
|
||||
it("reads latin1 high bytes as char codes", { timeout: 5000 }, () => {
|
||||
const buf = new Uint8Array([0xc9, 0xe9]);
|
||||
const result = readString(buf, 0, 2, "latin1");
|
||||
expect(result.charCodeAt(0)).toBe(0xc9);
|
||||
expect(result.charCodeAt(1)).toBe(0xe9);
|
||||
});
|
||||
|
||||
it("handles latin1 slice shorter than requested length (null byte fallback)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
// buf has 2 bytes but we request 4 chars at offset 0 — slice will be shorter
|
||||
// subarray(0, 4) gives only 2 bytes for a 2-byte buffer
|
||||
// When iterating, slice[2] and slice[3] would be undefined → 0 → '\0'
|
||||
const buf = new Uint8Array([0x41, 0x42]); // "AB"
|
||||
const result = readString(buf, 0, 4, "latin1");
|
||||
// Should have A, B, then null chars
|
||||
expect(result.charCodeAt(0)).toBe(0x41);
|
||||
expect(result.charCodeAt(1)).toBe(0x42);
|
||||
});
|
||||
});
|
||||
|
||||
describe("readSyncsafeInt", () => {
|
||||
it("decodes syncsafe integer", { timeout: 5000 }, () => {
|
||||
// 0x00 0x00 0x02 0x01 → 0 + 0 + (2<<7) + 1 = 257
|
||||
expect(
|
||||
readSyncsafeInt(new Uint8Array([0x00, 0x00, 0x02, 0x01]), 0)
|
||||
).toBe(257);
|
||||
});
|
||||
|
||||
it("decodes zero", { timeout: 5000 }, () => {
|
||||
expect(
|
||||
readSyncsafeInt(new Uint8Array([0x00, 0x00, 0x00, 0x00]), 0)
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("ignores high bits of each byte", { timeout: 5000 }, () => {
|
||||
// 0x80 in syncsafe = 0 (high bit ignored)
|
||||
expect(
|
||||
readSyncsafeInt(new Uint8Array([0x80, 0x00, 0x00, 0x01]), 0)
|
||||
).toBe(1);
|
||||
});
|
||||
|
||||
it("decodes a known tag size", { timeout: 5000 }, () => {
|
||||
// syncsafe 256 = [0x00, 0x00, 0x02, 0x00]
|
||||
expect(
|
||||
readSyncsafeInt(new Uint8Array([0x00, 0x00, 0x02, 0x00]), 0)
|
||||
).toBe(256);
|
||||
});
|
||||
|
||||
it("returns 0 for out-of-bounds offset", { timeout: 5000 }, () => {
|
||||
expect(readSyncsafeInt(new Uint8Array([0x01]), 10)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("concatBuffers", () => {
|
||||
it("concatenates two buffers", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([1, 2, 3]);
|
||||
const b = new Uint8Array([4, 5, 6]);
|
||||
const result = concatBuffers(a, b);
|
||||
expect(Array.from(result)).toEqual([1, 2, 3, 4, 5, 6]);
|
||||
});
|
||||
|
||||
it("handles empty first buffer", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([]);
|
||||
const b = new Uint8Array([1, 2]);
|
||||
expect(Array.from(concatBuffers(a, b))).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("handles empty second buffer", { timeout: 5000 }, () => {
|
||||
const a = new Uint8Array([1, 2]);
|
||||
const b = new Uint8Array([]);
|
||||
expect(Array.from(concatBuffers(a, b))).toEqual([1, 2]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Low-level binary helpers for reading structured data from a Uint8Array.
|
||||
*
|
||||
* All functions are pure, allocation-free (except `readString` and
|
||||
* `concatBuffers`), and safe for both Node.js and browser environments.
|
||||
*/
|
||||
|
||||
/** Read a single unsigned byte. */
|
||||
export function readUint8(buf: Uint8Array, offset: number): number {
|
||||
return buf[offset] ?? 0;
|
||||
}
|
||||
|
||||
/** Read a big-endian unsigned 16-bit integer. */
|
||||
export function readUint16BE(buf: Uint8Array, offset: number): number {
|
||||
return (((buf[offset] ?? 0) << 8) | (buf[offset + 1] ?? 0)) >>> 0;
|
||||
}
|
||||
|
||||
/** Read a little-endian unsigned 16-bit integer. */
|
||||
export function readUint16LE(buf: Uint8Array, offset: number): number {
|
||||
return (((buf[offset + 1] ?? 0) << 8) | (buf[offset] ?? 0)) >>> 0;
|
||||
}
|
||||
|
||||
/** Read a big-endian unsigned 32-bit integer. */
|
||||
export function readUint32BE(buf: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((buf[offset] ?? 0) * 0x1000000 +
|
||||
(((buf[offset + 1] ?? 0) << 16) |
|
||||
((buf[offset + 2] ?? 0) << 8) |
|
||||
(buf[offset + 3] ?? 0))) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a little-endian unsigned 32-bit integer. */
|
||||
export function readUint32LE(buf: Uint8Array, offset: number): number {
|
||||
return (
|
||||
((buf[offset + 3] ?? 0) * 0x1000000 +
|
||||
(((buf[offset + 2] ?? 0) << 16) |
|
||||
((buf[offset + 1] ?? 0) << 8) |
|
||||
(buf[offset] ?? 0))) >>>
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/** Read a big-endian signed 32-bit integer. */
|
||||
export function readInt32BE(buf: Uint8Array, offset: number): number {
|
||||
const u = readUint32BE(buf, offset);
|
||||
return u >= 0x80000000 ? u - 0x100000000 : u;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a run of bytes as a string.
|
||||
*
|
||||
* @param encoding - `"utf8"` (default) uses `TextDecoder`; `"latin1"` maps each byte directly.
|
||||
*/
|
||||
export function readString(
|
||||
buf: Uint8Array,
|
||||
offset: number,
|
||||
length: number,
|
||||
encoding: "utf8" | "latin1" = "utf8"
|
||||
): string {
|
||||
const slice = buf.subarray(offset, offset + length);
|
||||
if (encoding === "latin1") {
|
||||
let out = "";
|
||||
for (let i = 0; i < slice.length; i++) {
|
||||
// i < slice.length guarantees slice[i] is defined; ?? 0 is defensive only
|
||||
out += String.fromCharCode(slice[i] as number);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return new TextDecoder("utf-8").decode(slice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a 4-byte ID3 syncsafe integer starting at `offset`.
|
||||
*
|
||||
* In a syncsafe integer the most-significant bit of each byte is always zero,
|
||||
* so the value is spread across the lower 7 bits of four bytes.
|
||||
*/
|
||||
export function readSyncsafeInt(buf: Uint8Array, offset: number): number {
|
||||
return (
|
||||
(((buf[offset] ?? 0) & 0x7f) << 21) |
|
||||
(((buf[offset + 1] ?? 0) & 0x7f) << 14) |
|
||||
(((buf[offset + 2] ?? 0) & 0x7f) << 7) |
|
||||
((buf[offset + 3] ?? 0) & 0x7f)
|
||||
);
|
||||
}
|
||||
|
||||
/** Concatenate two `Uint8Array` buffers into a new one. */
|
||||
export function concatBuffers(a: Uint8Array, b: Uint8Array): Uint8Array {
|
||||
const out = new Uint8Array(a.length + b.length);
|
||||
out.set(a, 0);
|
||||
out.set(b, a.length);
|
||||
return out;
|
||||
}
|
||||
11
packages/media/audio/audio-metadata/src/utils/index.ts
Normal file
11
packages/media/audio/audio-metadata/src/utils/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
concatBuffers,
|
||||
readInt32BE,
|
||||
readString,
|
||||
readSyncsafeInt,
|
||||
readUint8,
|
||||
readUint16BE,
|
||||
readUint16LE,
|
||||
readUint32BE,
|
||||
readUint32LE,
|
||||
} from "./buffer-utils";
|
||||
32
packages/media/audio/audio-metadata/tests/dep-graph.test.ts
Normal file
32
packages/media/audio/audio-metadata/tests/dep-graph.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("dep-graph", () => {
|
||||
it("has no @suaveplan/* runtime dependencies", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as {
|
||||
dependencies?: Record<string, string>;
|
||||
peerDependencies?: Record<string, string>;
|
||||
};
|
||||
const runtimeScoped = Object.keys(pkg.dependencies ?? {})
|
||||
.filter((name) => name.startsWith("@suaveplan/"))
|
||||
.sort();
|
||||
expect(runtimeScoped).toEqual([]);
|
||||
const peerScoped = Object.keys(pkg.peerDependencies ?? {}).filter(
|
||||
(name) => name.startsWith("@suaveplan/")
|
||||
);
|
||||
expect(peerScoped).toEqual([]);
|
||||
});
|
||||
|
||||
it("runtime deps are exactly the expected set (sorted)", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { dependencies?: Record<string, string> };
|
||||
const runtime = Object.keys(pkg.dependencies ?? {}).sort();
|
||||
expect(runtime).toEqual(["@opentelemetry/api", "tslib", "zod"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { readdirSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
const SRC = join(__dirname, "..", "src");
|
||||
|
||||
const BANNED: ReadonlyArray<RegExp> = [
|
||||
/\bnode:fs\b/,
|
||||
/\bnode:path\b/,
|
||||
/\bnode:url\b/,
|
||||
/\bprocess\./,
|
||||
/\bBuffer\b/,
|
||||
/\bdocument\b/,
|
||||
/\bwindow\b/,
|
||||
];
|
||||
|
||||
function* walk(dir: string): Generator<string> {
|
||||
for (const entry of readdirSync(dir)) {
|
||||
const full = join(dir, entry);
|
||||
const st = statSync(full);
|
||||
if (st.isDirectory()) yield* walk(full);
|
||||
else if (full.endsWith(".ts") && !full.endsWith(".test.ts")) yield full;
|
||||
}
|
||||
}
|
||||
|
||||
describe("no-disallowed", () => {
|
||||
it("src/ does not reference Node or browser built-ins", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const hits: string[] = [];
|
||||
for (const file of walk(SRC)) {
|
||||
const content = readFileSync(file, "utf8");
|
||||
for (const pattern of BANNED) {
|
||||
if (pattern.test(content)) {
|
||||
hits.push(`${file} matched ${pattern}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
expect(hits).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
export {};
|
||||
20
packages/media/audio/audio-metadata/tests/tree-shake.test.ts
Normal file
20
packages/media/audio/audio-metadata/tests/tree-shake.test.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
|
||||
describe("tree-shake", () => {
|
||||
it("sideEffects is false", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { sideEffects?: boolean };
|
||||
expect(pkg.sideEffects).toBe(false);
|
||||
});
|
||||
|
||||
it("exports has at least 5 entries", { timeout: 5000 }, () => {
|
||||
const pkg = JSON.parse(
|
||||
readFileSync(join(__dirname, "..", "package.json"), "utf8")
|
||||
) as { exports?: Record<string, unknown> };
|
||||
expect(pkg.exports).toBeDefined();
|
||||
expect(Object.keys(pkg.exports ?? {}).length).toBeGreaterThanOrEqual(5);
|
||||
});
|
||||
});
|
||||
12
packages/media/audio/audio-metadata/tsconfig.lib.json
Normal file
12
packages/media/audio/audio-metadata/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
20
packages/media/audio/audio-metadata/vite.config.ts
Normal file
20
packages/media/audio/audio-metadata/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/metadata",
|
||||
plugins: [dts({ entryRoot: "src", tsconfigPath: "./tsconfig.lib.json", insertTypesEntry: true })],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: { preserveModules: true, preserveModulesRoot: "src", entryFileNames: "[name].js" },
|
||||
external: ["tslib", "zod", "@opentelemetry/api", /^node:/],
|
||||
},
|
||||
},
|
||||
});
|
||||
28
packages/media/audio/audio-metadata/vitest.config.ts
Normal file
28
packages/media/audio/audio-metadata/vitest.config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
setupFiles: ["./tests/setup/polyfills.ts"],
|
||||
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
|
||||
exclude: ["src/__benchmarks__/**", "dist/**", "node_modules/**"],
|
||||
coverage: {
|
||||
provider: "istanbul",
|
||||
reporter: ["text", "html", "lcov"],
|
||||
include: ["src/**/*.ts"],
|
||||
exclude: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.bench.ts",
|
||||
"src/**/*.schemas.ts",
|
||||
"src/**/*.types.ts",
|
||||
"src/**/index.ts",
|
||||
"src/__benchmarks__/**",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: { statements: 100, branches: 100, functions: 100, lines: 100 },
|
||||
},
|
||||
testTimeout: 30000,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user