first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

File diff suppressed because one or more lines are too long

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View 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.

View File

@@ -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.