# 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`. 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`, `extractFromArrayBuffer(buf, options?): Promise`, `extractFromFile(file: { arrayBuffer(): Promise }, options?): Promise`, `extractAudioMetadata(buf, options?): Promise`. A module-level convenience `extractMetadata(buf, options?): Promise` 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` 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)