Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,561 @@
<!-- markdownlint-disable MD024 -->
# Capability Spec: audio-metadata
**Package**: `@suaveplan/audio-metadata`
**Status**: STABLE
**Tier**: universal
> Every requirement below carries a `**Verified by:**` line anchored to a real test in
> `packages/media/audio/audio-metadata/`. No exceptions.
`@suaveplan/audio-metadata` is the universal audio-metadata reader: it detects audio
container formats by magic bytes, dispatches to a format-specific parser, and normalises
container-native tags (ID3v1, ID3v2, iTunes ilst, Vorbis comments, RIFF INFO, AIFF/ID3)
into a single `Track` shape. The package is environment-agnostic (Node ≥18, browser, Deno),
zero-deps from `@suaveplan/*`, and never throws on malformed buffers — parsers return
partial `Track` objects for unrecognised or truncated input.
## Requirements
### Requirement: Universal-tier purity with zero workspace runtime deps
The package SHALL import zero browser-only APIs (`window`, `document`, `navigator`),
zero Node-only APIs (`node:fs`, `node:path`, `process`), and SHALL NOT depend on any
`@suaveplan/*` workspace package at runtime. The only non-workspace runtime dependency
permitted is `@opentelemetry/api`, loaded dynamically and gracefully absent on failure.
`tslib` is permitted as a build helper.
#### Scenario: dependency closure contains no `@suaveplan/*` runtime entry
- **WHEN** `package.json` `dependencies` is inspected
- **THEN** no key starts with `@suaveplan/`
#### Scenario: built dist references no platform globals
- **WHEN** `dist/` is grep'd for `window`, `document`, `node:fs`, `node:path`, `process`,
`AudioContext`, `Buffer`
- **THEN** the count is zero
**Verified by:** `tests/dep-graph.test.ts`, `tests/no-disallowed.test.ts`
---
### Requirement: Eight container-format detection via magic-byte sniffing
The package SHALL export `detectAudioFormat(buffer: Uint8Array): AudioFormat` returning
one of `"id3v2" | "id3v1" | "mp4" | "flac" | "ogg" | "wav" | "aiff" | "unknown"`.
Detection SHALL probe in this order so a file carrying both ID3v2 header and ID3v1 trailer
resolves as `"id3v2"`: (1) ID3v2 at byte 0 (`ID3`); (2) FLAC at byte 0 (`fLaC`); (3) Ogg
at byte 0 (`OggS`); (4) WAV (`RIFF` at 0 AND `WAVE` at 8); (5) AIFF (`FORM` at 0 AND
`AIFF` or `AIFC` at 8); (6) MP4 (`ftyp` or `moov` at byte 4); (7) ID3v1 trailer (`TAG`
at offset `length - 128`); (8) `"unknown"`.
#### Scenario: ID3v2 detected before ID3v1 when both present
- **WHEN** a buffer carries `ID3` at byte 0 and `TAG` at length-128
- **THEN** `detectAudioFormat` returns `"id3v2"`
#### Scenario: WAV requires both RIFF and WAVE
- **WHEN** the buffer starts with `RIFF` but byte 8 onwards is not `WAVE`
- **THEN** `detectAudioFormat` does not return `"wav"`
#### Scenario: AIFC accepted as AIFF variant
- **WHEN** a buffer carries `FORM` at byte 0 and `AIFC` at byte 8
- **THEN** `detectAudioFormat` returns `"aiff"`
#### Scenario: empty or short buffer returns unknown
- **WHEN** `detectAudioFormat(new Uint8Array(0))` is called
- **THEN** the return value is `"unknown"`
**Verified by:** `src/parsers/format-detector/format-detector.test.ts` (every detection test)
---
### Requirement: Track normalisation across all tag families
The package SHALL normalise tags from every supported container into a single `Track`
interface with optional fields `title`, `artist`, `album`, `albumArtist`, `year (number)`,
`trackNumber`, `totalTracks`, `discNumber`, `totalDiscs`, `genre`, `comment`, `lyrics`,
`cover: CoverArt`, `duration (seconds)`, `bitrate (kbps)`, `sampleRate (Hz)`, `channels`,
`format: AudioFormat`, `rawTags: Record<string, string | string[]>`. Absent fields SHALL
use `undefined`, not `null`. Tags carrying multiple values (Vorbis comment, TXXX) SHALL
be preserved as `string[]` in `rawTags` while normalised `track.*` fields take the first
value.
#### Scenario: ID3v2 TRCK maps to trackNumber and totalTracks
- **WHEN** `parseId3v2` reads a TRCK frame value `"3/12"`
- **THEN** `track.trackNumber === 3` and `track.totalTracks === 12`
#### Scenario: MP4 trkn 6-byte structure maps correctly
- **WHEN** `parseMp4` reads a trkn atom whose data is `[0, 0, 0, 5, 0, 10]` (2 padding +
2 number + 2 total, big-endian)
- **THEN** `track.trackNumber === 5` and `track.totalTracks === 10`
#### Scenario: Vorbis multi-value tags preserved as array
- **WHEN** `parseVorbisCommentBlock` reads two `ARTIST=` entries
- **THEN** `track.artist` equals the first value and `track.rawTags.ARTIST` is the array
**Verified by:** `src/parsers/id3v2/id3v2.test.ts::"parses track number (TRCK)"`,
`src/parsers/mp4-atom/mp4-atom.test.ts::"parses trkn (track number)"`,
`src/parsers/flac-vorbis/flac-vorbis.test.ts::"parses basic Vorbis comment tags"`
---
### Requirement: ID3v1 / ID3v1.1 parsing
`parseId3v1(buffer: Uint8Array): Track` SHALL parse the fixed 128-byte tag at
`buffer.length - 128`. It SHALL extract title (30 bytes), artist (30), album (30),
year (4 numeric chars or `undefined` if non-numeric), comment (28 or 30 bytes),
and genre (1 byte index into the 192-entry Winamp-extended list). It SHALL detect ID3v1.1
track-number extension via the comment byte-28 = 0 sentinel and store the trailing byte
as `track.trackNumber`. Strings SHALL be decoded as Latin-1 (ISO-8859-1) and stripped of
trailing null + whitespace via regex. Returns `{}` when `TAG` magic is absent or buffer
is shorter than 128 bytes.
#### Scenario: ID3v1.1 track number recognised
- **WHEN** a 128-byte tag has byte 28 = 0x00 and byte 29 ≠ 0
- **THEN** `track.trackNumber` is byte 29 and `track.comment` is the 28-byte slice
#### Scenario: non-numeric year ignored
- **WHEN** the four year bytes contain non-digit characters
- **THEN** `track.year` is `undefined`
#### Scenario: empty result for missing magic or short buffer
- **WHEN** the buffer is shorter than 128 bytes or `TAG` magic is absent
- **THEN** parser returns `{}`
**Verified by:** `src/parsers/id3v1/id3v1.test.ts::"parses track number (ID3v1.1)"`,
`src/parsers/id3v1/id3v1.test.ts::"ignores non-numeric year string"`,
`src/parsers/id3v1/id3v1.test.ts::"returns empty track when buffer too short"`
---
### Requirement: ID3v2.2 / 2.3 / 2.4 parsing with full text encoding support
`parseId3v2(buffer, options?)` SHALL parse ID3v2 tags at byte 0 supporting all three
minor versions (v2.2, v2.3, v2.4). Frame IDs from v2.2 (TT2, TP1, TAL, TRK, TYE, TCO,
COM, ULT, PIC) SHALL be normalised to their v2.3/v2.4 equivalents (TIT2, TPE1, TALB,
TRCK, TYER, TCON, COMM, USLT, APIC). The parser SHALL decode all four text encodings:
ISO-8859-1 (0x00), UTF-16 with BOM (0x01), UTF-16 BE without BOM (0x02), UTF-8 (0x03).
It SHALL handle whole-tag unsync flag (v2.2 / v2.3), extended headers (v2.3+), and
syncsafe size integers (4 × 7 bits = 28 bits, 256 MiB max). Numeric genre references
in TCON (e.g. `(17)`, `17`, `(17)Rock`) SHALL be resolved against the Winamp-extended
list and concatenated with any free text suffix.
#### Scenario: TIT2 frame parsed as title
- **WHEN** a tag carries a UTF-8 TIT2 frame with `"Song"`
- **THEN** `track.title === "Song"`
#### Scenario: TDRC ISO-date year parsed
- **WHEN** the tag carries a v2.4 TDRC frame with `"2023-04-15"`
- **THEN** `track.year === 2023`
#### Scenario: skipCovers option suppresses APIC extraction
- **WHEN** caller passes `{ skipCovers: true }`
- **THEN** `track.cover` is `undefined` even if an APIC frame is present
#### Scenario: skipLyrics option suppresses USLT extraction
- **WHEN** caller passes `{ skipLyrics: true }`
- **THEN** `track.lyrics` is `undefined` even if a USLT frame is present
**Verified by:** `src/parsers/id3v2/id3v2.test.ts::"parses title frame (TIT2)"`,
`src/parsers/id3v2/id3v2.test.ts::"parses year from TDRC (ISO date)"`,
`src/parsers/id3v2/id3v2.test.ts::"respects skipCovers option"`,
`src/parsers/id3v2/id3v2.test.ts::"respects skipLyrics option"`
---
### Requirement: MP4 iTunes ilst atom parsing
`parseMp4(buffer, options?)` SHALL traverse the MP4 box hierarchy `moov → udta → meta →
ilst` (and the fallback `moov → meta → ilst` path some encoders use) and decode iTunes
atoms with the prefixes `©nam` (title), `©ART` (artist), `©alb` (album), `aART` (album
artist), `©day` (year), `©cmt` (comment), `©lyr` (lyrics), `©gen` (text genre), `gnre`
(numeric genre index, 1-based into ID3v1 list), `trkn` (track 6-byte structure),
`disk` (disc 6-byte structure), `covr` (embedded artwork). 64-bit extended boxes
(size sentinel = 1) SHALL be skipped without crashing. Returns `{}` if no `moov` box
is present; returns `{ format: "mp4" }` if `moov` exists but no `ilst`.
#### Scenario: ©day year parsed
- **WHEN** a buffer carries an `©day` atom with `"2024"`
- **THEN** `track.year === 2024`
#### Scenario: covr atom MIME type detection
- **WHEN** the covr data atom flag is `0x0d`
- **THEN** `track.cover.mimeType === "image/jpeg"`
- **WHEN** the flag is `0x0e`
- **THEN** `track.cover.mimeType === "image/png"`
#### Scenario: gnre index 0 ignored
- **WHEN** the `gnre` atom encodes index 0
- **THEN** `track.genre` is `undefined` (1-based; 0 is invalid)
**Verified by:** `src/parsers/mp4-atom/mp4-atom.test.ts::"parses ©day (year)"`,
`src/parsers/mp4-atom/mp4-atom.test.ts::"parses covr (PNG cover art)"`,
`src/parsers/mp4-atom/mp4-atom.test.ts::"ignores gnre with index 0 (invalid)"`
---
### Requirement: FLAC + Vorbis comment block parsing
`parseFlac(buffer)` SHALL verify the `fLaC` magic, walk METADATA_BLOCKs, extract
stream properties from STREAMINFO (block type 0): sampleRate (20 bits crossing byte
boundaries), channels (3-bit field +1), totalSamples (36 bits = 4-bit high + 32-bit low)
for duration computation. It SHALL extract VORBIS_COMMENT (block type 4) via
`parseVorbisCommentBlock`. The exported `parseVorbisCommentBlock(data: Uint8Array): Track`
SHALL parse the raw comment binary (uint32LE vendor length, uint32LE comment count,
per-comment uint32LE length + UTF-8 `key=value`). Recognised keys (case-insensitive,
stored uppercase in `rawTags`): `TITLE`, `ARTIST`, `ALBUM`, `ALBUMARTIST`, `DATE`,
`TRACKNUMBER`, `TRACKTOTAL`/`TOTALTRACKS`, `DISCNUMBER`, `DISCTOTAL`/`TOTALDISCS`,
`GENRE`, `COMMENT`, `LYRICS`/`UNSYNCEDLYRICS`. Multiple same-key entries SHALL be
preserved as `string[]` in `rawTags`.
#### Scenario: duration computed from STREAMINFO totalSamples
- **WHEN** STREAMINFO carries `sampleRate=44100` and `totalSamples=441000`
- **THEN** `track.duration === 10`
#### Scenario: TOTALDISCS aliases DISCTOTAL
- **WHEN** Vorbis comment carries `TOTALDISCS=2`
- **THEN** `track.totalDiscs === 2`
#### Scenario: block claiming length beyond buffer breaks safely
- **WHEN** a metadata block declares a length that extends past the buffer end
- **THEN** parsing halts and the partial Track is returned (no throw)
**Verified by:** `src/parsers/flac-vorbis/flac-vorbis.test.ts::"computes duration when streaminfo has non-zero totalSamples"`,
`src/parsers/flac-vorbis/flac-vorbis.test.ts::"parses TOTALDISCS alias"`,
`src/parsers/flac-vorbis/flac-vorbis.test.ts::"breaks when block claims length beyond buffer"`
---
### Requirement: Ogg Vorbis logical-bitstream reconstruction
`parseOgg(buffer)` SHALL verify `OggS` magic, walk Ogg pages (27-byte header + segment
table + data), and reconstruct logical bitstream packets. It SHALL collect at most three
packets (ident, comment, setup); packets are terminated by a segment length < 255.
The comment packet SHALL be located by its signature `0x03 + "vorbis"`. The ident packet
(type 0x01) SHALL yield `sampleRate` (4-byte little-endian at offset 12) and `channels`
(byte 11). When the packet walker fails (truncated segment table or page header), the
parser SHALL fall back to a linear scan of the first page data for the comment
signature. Returns `{ format: "ogg" }` if magic matches but no comment packet found.
#### Scenario: sampleRate and channels read from ident packet
- **WHEN** an Ogg file's first packet (type 0x01) declares 44_100 Hz / 2 channels
- **THEN** `track.sampleRate === 44_100` and `track.channels === 2`
#### Scenario: fallback linear scan finds comment when walker breaks
- **WHEN** the packet walker fails on a multi-page stream but the comment lies on the
first page's data
- **THEN** the parser recovers it via linear scan
#### Scenario: short or truncated page returns empty
- **WHEN** the buffer is shorter than 27 bytes or a declared page extends past the buffer end
- **THEN** the parser returns `{}` without throwing
**Verified by:** `src/parsers/ogg-vorbis/ogg-vorbis.test.ts::"reads sampleRate and channels from ident packet"`,
`src/parsers/ogg-vorbis/ogg-vorbis.test.ts::"uses fallback linear scan when collectOggPackets misses comment"`,
`src/parsers/ogg-vorbis/ogg-vorbis.test.ts::"readOggPage returns null when buffer is shorter than 27 bytes (pos+27 > buf.length)"`
---
### Requirement: WAV RIFF INFO + fmt chunk parsing
`parseWav(buffer)` SHALL verify `RIFF` (offset 0) + `WAVE` (offset 8) magic and walk
RIFF chunks (4-byte FourCC + 4-byte uint32LE size + data; chunks padded to even
boundaries). The `fmt ` chunk SHALL yield `channels` (2LE at +2), `sampleRate` (4LE at +4),
`byteRate` (4LE at +8) — bitrate derived as `(byteRate * 8) / 1000` kbps. The `data`
chunk size, when present after `fmt `, SHALL yield a duration estimate assuming 16-bit
PCM (`samples = size / (2 * channels)`). A `LIST INFO` sub-chunk SHALL be walked for
INFO fields: `INAM` → title, `IART` → artist, `IPRD` → album, `IGNR` → genre, `ICMT` →
comment, `ICRD` → year. Returns `{}` if magic mismatched or buffer shorter than 12 bytes.
#### Scenario: bitrate computed from byteRate
- **WHEN** `fmt ` chunk declares byteRate = 176_400
- **THEN** `track.bitrate === 1411`
#### Scenario: LIST without INFO type ignored
- **WHEN** a LIST chunk's type is not `INFO`
- **THEN** the LIST is skipped without affecting other fields
#### Scenario: data chunk before fmt yields no duration
- **WHEN** the file has a `data` chunk but no preceding `fmt `
- **THEN** `track.duration` is `undefined`
**Verified by:** `src/parsers/wav-riff/wav-riff.test.ts::"computes bitrate from fmt byteRate"`,
`src/parsers/wav-riff/wav-riff.test.ts::"ignores non-INFO LIST chunk"`,
`src/parsers/wav-riff/wav-riff.test.ts::"handles data chunk without prior fmt chunk (no duration)"`
---
### Requirement: AIFF / AIFC parsing with IEEE 754 80-bit extended float
`parseAiff(buffer, options?)` SHALL verify `FORM` (0) + `AIFF` or `AIFC` (8) magic and
walk AIFF chunks (4-byte FourCC + 4-byte uint32**BE** size + data; even-byte padding —
unlike WAV, AIFF uses big-endian sizes). The `COMM` chunk SHALL yield `channels`
(2BE at offset 0), `numSampleFrames` (4BE at offset 2), and `sampleRate` (80-bit
extended IEEE 754 at offset 8: 1 sign bit + 15 exponent bits biased by 16383 + 64-bit
explicit mantissa, no implicit 1 bit). The `ID3 ` chunk (also accepts `id3 ` lowercase
and the variant with trailing space) SHALL delegate to `parseId3v2` with the same
`options` and merge results.
#### Scenario: COMM IEEE 754 extended decoded to sampleRate
- **WHEN** a COMM chunk encodes `44_100` in 80-bit extended form
- **THEN** `track.sampleRate === 44_100`
#### Scenario: AIFC variant accepted
- **WHEN** byte 8 is `AIFC` instead of `AIFF`
- **THEN** the parser still runs and emits `track.format === "aiff"`
#### Scenario: COMM smaller than 18 bytes ignored
- **WHEN** the COMM chunk size is less than 18 bytes (insufficient for the full block)
- **THEN** the parser skips it without throwing
#### Scenario: negative sampleRate via IEEE 754 sign bit
- **WHEN** the 80-bit extended float has its sign bit set
- **THEN** the parser still returns a numeric `sampleRate` (sign-extended path covered)
**Verified by:** `src/parsers/aiff-id3/aiff-id3.test.ts::"parses COMM chunk for sample rate and channels"`,
`src/parsers/aiff-id3/aiff-id3.test.ts::"handles AIFC format"`,
`src/parsers/aiff-id3/aiff-id3.test.ts::"ignores COMM chunk smaller than 18 bytes"`,
`src/parsers/aiff-id3/aiff-id3.test.ts::"returns negative sample rate via IEEE 754 sign bit (parseIeee754Extended -f path)"`
---
### Requirement: Cover-art extraction with size guard
When `skipCovers !== true`, parsers SHALL extract embedded artwork into
`track.cover: { data: Uint8Array; mimeType: string; description?: string }`. MIME type
detection: APIC frames read the in-frame MIME string (defaulting to `image/jpeg`); MP4
covr atoms use the flag byte (`0x0d` → JPEG, `0x0e` → PNG). The default `maxCoverSize`
SHALL be 5 MiB (5_242_880 bytes); covers larger than this SHALL be dropped silently
(returning `track.cover === undefined`). Callers MAY override via the `maxCoverSize`
option.
#### Scenario: skipCovers suppresses extraction across parsers
- **WHEN** caller passes `{ skipCovers: true }` to `extractMetadata` or any individual parser
- **THEN** `track.cover` is `undefined` regardless of input
#### Scenario: PNG cover art from MP4 detected
- **WHEN** an MP4 covr data box has flag `0x0e`
- **THEN** `track.cover.mimeType === "image/png"`
**Verified by:** `src/core/extractor/extractor.test.ts::"respects skipCovers option"`,
`src/parsers/mp4-atom/mp4-atom.test.ts::"parses covr (PNG cover art)"`,
`src/parsers/id3v2/id3v2.test.ts::"respects skipCovers option"`
---
### Requirement: Lyrics extraction with skipLyrics opt-out
When `skipLyrics !== true`, parsers SHALL extract lyrics text into `track.lyrics`.
Sources: ID3v2 USLT frame (after stripping language code + description prefix), MP4
`©lyr` atom, Vorbis `LYRICS` / `UNSYNCEDLYRICS` keys. The `skipLyrics` option SHALL
suppress extraction across all parsers.
#### Scenario: USLT extracted by default
- **WHEN** an ID3v2 buffer carries a USLT frame
- **THEN** `track.lyrics` contains the parsed text
#### Scenario: skipLyrics drops MP4 ©lyr
- **WHEN** caller passes `{ skipLyrics: true }` to extractor against an MP4 file with `©lyr`
- **THEN** `track.lyrics` is `undefined`
**Verified by:** `src/parsers/id3v2/id3v2.test.ts::"parses USLT (lyrics) frame"`,
`src/parsers/id3v2/id3v2.test.ts::"respects skipLyrics option"`,
`src/core/extractor/extractor.test.ts::"respects skipLyrics option"`
---
### Requirement: MetadataExtractor singleton facade
The package SHALL export `MetadataExtractor` with `getInstance(): MetadataExtractor` and
instance methods `extractFromBuffer(buf, options?): Promise<Track>`,
`extractFromArrayBuffer(buf, options?): Promise<Track>`,
`extractFromFile(file: { arrayBuffer(): Promise<ArrayBuffer> }, options?): Promise<Track>`,
`extractAudioMetadata(buf, options?): Promise<AudioMetadata>`.
A module-level convenience `extractMetadata(buf, options?): Promise<Track>` SHALL delegate
to the singleton. The extractor SHALL dispatch on `detectAudioFormat` and wrap parser
failures in `MetadataError(PARSE_ERROR)`. `extractFromFile` SHALL throw
`MetadataError(FETCH_FAILED)` when the file-like object's `arrayBuffer()` rejects.
Unknown formats SHALL throw `MetadataError(UNSUPPORTED_FORMAT)`.
#### Scenario: singleton identity
- **WHEN** `MetadataExtractor.getInstance()` is called twice
- **THEN** both calls return the same reference
#### Scenario: unknown buffer throws UNSUPPORTED_FORMAT
- **WHEN** a random 256-byte buffer with no recognised magic is passed to `extractFromBuffer`
- **THEN** the call rejects with `MetadataError(METADATA.UNSUPPORTED_FORMAT)`
#### Scenario: rejecting `arrayBuffer()` translates to FETCH_FAILED
- **WHEN** a Blob-like object whose `arrayBuffer()` rejects with any value is passed to
`extractFromFile`
- **THEN** the call rejects with `MetadataError(METADATA.FETCH_FAILED)`
**Verified by:** `src/core/extractor/extractor.test.ts::"is a singleton"`,
`src/core/extractor/extractor.test.ts::"throws MetadataError for unknown format"`,
`src/core/extractor/extractor.test.ts::"extractFromFile throws FETCH_FAILED on rejection"`,
`src/core/extractor/extractor.test.ts::"extractFromFile throws FETCH_FAILED when non-Error thrown from arrayBuffer"`
---
### Requirement: Stable error code registry under METADATA.* namespace
The package SHALL export `METADATA_CODES` containing exactly these three codes:
`METADATA.UNSUPPORTED_FORMAT`, `METADATA.PARSE_ERROR`, `METADATA.FETCH_FAILED`.
The package SHALL export a `MetadataError extends Error` class with public fields
`code: MetadataCode` and `name === "MetadataError"`. This class deliberately does
**not** extend `SuaveplanError` so the package can remain a leaf with no `@suaveplan/*`
runtime deps.
#### Scenario: error code namespacing
- **WHEN** caller reads `METADATA_CODES.UNSUPPORTED_FORMAT`
- **THEN** value is exactly `"METADATA.UNSUPPORTED_FORMAT"`
#### Scenario: MetadataError name is stable for instanceof-free discrimination
- **WHEN** an error is constructed and inspected
- **THEN** `err.name === "MetadataError"` and `err.code` matches the constructor arg
**Verified by:** `src/core/extractor/extractor.test.ts::"MetadataError has correct code and name"`
---
### Requirement: Buffer utility primitives
The package SHALL export `readUint8`, `readUint16BE`, `readUint16LE`, `readUint32BE`,
`readUint32LE`, `readInt32BE`, `readString(buf, offset, length, encoding?: "utf8" | "latin1")`,
`readSyncsafeInt(buf, offset)`, `concatBuffers(a, b)` from `./utils`. All `read*`
functions SHALL return `0` (or `""` for `readString`) on out-of-bounds access rather than
throwing — trading silent safety for robustness against truncated buffers.
`readUint32BE` SHALL use multiplication by `0x1000000` (not `<< 24`) so high-MSB bytes
do not flip to negative. `readSyncsafeInt` SHALL decode the 4-byte ID3v2 syncsafe form
(7 bits per byte, MSB always 0) yielding a 28-bit max of 268_435_455.
#### Scenario: out-of-bounds read returns 0
- **WHEN** `readUint16BE(buf, buf.length + 100)` is called
- **THEN** the return value is `0`
#### Scenario: high MSB byte does not sign-extend
- **WHEN** `readUint32BE` reads a byte sequence whose first byte ≥ 0x80
- **THEN** the result is unsigned (≥ 2_147_483_648)
#### Scenario: syncsafe int decoded
- **WHEN** `readSyncsafeInt` reads `[0x00, 0x00, 0x02, 0x01]`
- **THEN** the result is `257` (0x101 from 7-bit chunks)
**Verified by:** `src/utils/buffer-utils.test.ts` (every read* test)
---
### Requirement: OpenTelemetry instrumentation, optional and silent on absence
The extractor SHALL emit OTel telemetry under meter name `"@suaveplan/audio-metadata"`:
a counter `metadata.extract.total{format}` incremented after each successful extraction,
and a histogram `metadata.extract.duration_ms{format}` recording elapsed time via
`performance.now()`. Both instruments SHALL be acquired via dynamic `import("@opentelemetry/api")`
inside the singleton; if the import fails or the API throws, instrumentation SHALL be
silently disabled and the extractor MUST still return correct results.
#### Scenario: instrumentation absent under no-OTel environment
- **WHEN** `extractFromBuffer` runs in a Node environment without `@opentelemetry/api`
installed
- **THEN** the result resolves correctly (no thrown error) and no telemetry is emitted
#### Scenario: format label populated on success
- **WHEN** the extractor processes a buffer with ID3v2 magic
- **THEN** the counter increment carries `{ format: "id3v2" }`
**Verified by:** `src/core/extractor/extractor.test.ts::"extracts metadata from ID3v2 buffer"`
(implicitly covers OTel happy-path via mocked OTel registration in `tests/setup`)
---
### Requirement: AudioMetadata envelope distinct from Track
The package SHALL export `extractAudioMetadata(buf, options?): Promise<AudioMetadata>`
returning `{ format: AudioFormat; track: Track; duration?: number; bitrate?: number }`.
The envelope's `format` and the embedded `track.format` SHALL agree. The envelope is
the only path that surfaces detected format alongside the full normalised Track for
callers who want both atomically.
#### Scenario: envelope format matches track.format
- **WHEN** `extractAudioMetadata` returns successfully against a FLAC buffer
- **THEN** `result.format === "flac"` and `result.track.format === "flac"`
**Verified by:** `src/core/extractor/extractor.test.ts::"extractAudioMetadata returns AudioMetadata envelope"`
---
### Requirement: Subpath exports per parser
The package SHALL expose, in addition to the root barrel, subpath exports for each
parser module so adapters can tree-shake unused parsers:
`./parsers/id3v1`, `./parsers/id3v2`, `./parsers/mp4-atom`, `./parsers/flac-vorbis`,
`./parsers/ogg-vorbis`, `./parsers/wav-riff`, `./parsers/aiff-id3`, plus `./errors`.
#### Scenario: per-parser subpath resolves
- **WHEN** a consumer imports `@suaveplan/audio-metadata/parsers/flac-vorbis`
- **THEN** resolution succeeds and exports `parseFlac` and `parseVorbisCommentBlock`
**Verified by:** `tests/tree-shake.test.ts`
---
### Requirement: 100% statement, branch, function, and line coverage
The package SHALL achieve 100% Istanbul coverage on every `.ts` under `src/` excluding
`.test.ts`, `.spec.ts`, `.types.ts`, and barrels. Coverage gate failure SHALL block
publish.
#### Scenario: coverage gate
- **WHEN** `bunx turbo test:coverage --filter=@suaveplan/audio-metadata` runs
- **THEN** statements / branches / functions / lines are each `100`
**Verified by:** `bunx turbo test:coverage --filter=@suaveplan/audio-metadata` (CI gate)