Files
suaveplan/openspec/specs/audio-metadata/spec.md
2026-05-27 23:29:53 +01:00

562 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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)