Initial Commit
This commit is contained in:
561
openspec/specs/audio-metadata/spec.md
Normal file
561
openspec/specs/audio-metadata/spec.md
Normal 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)
|
||||
Reference in New Issue
Block a user