Initial Commit
This commit is contained in:
@@ -0,0 +1,342 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: audio-codecs (audio-codecs)
|
||||
|
||||
**Package**: `@suaveplan/audio-codecs`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Universal-tier purity
|
||||
|
||||
The `@suaveplan/audio-codecs` package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`, `AudioBuffer`) and zero statically-imported Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). Filesystem access SHALL be reached only through runtime-gated dynamic `import('node:fs/promises')` inside source-resolution code paths, never as a top-level import. The package's `dependencies` SHALL be limited to `@suaveplan/audio-core`, `@suaveplan/audio-metadata`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency. WASM codec packages SHALL be declared as `optionalDependencies` and loaded only via dynamic `import()`.
|
||||
|
||||
#### Scenario: Built dist references no platform globals at module scope
|
||||
|
||||
- **WHEN** the built `dist/` is grep'd for top-level imports
|
||||
- **THEN** it MUST NOT statically import `window`, `document`, `navigator`, `AudioContext`, `node:fs`, `node:path`, `process`, or `Buffer` — `node:fs/promises` appears only inside a dynamic `import()` expression
|
||||
|
||||
#### Scenario: Tests pass under both runtimes
|
||||
|
||||
- **WHEN** the suite runs under `bun test` (server) and under vitest with a `happy-dom` shim (browser)
|
||||
- **THEN** both runs pass with identical results, with `fetch`-backed source resolution exercised in the browser run and `fs`-backed resolution exercised in the server run
|
||||
|
||||
**Verified by:** `tests/purity.test.ts::"dist statically imports no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CodecRegistry plugin management
|
||||
|
||||
The package SHALL export a `CodecRegistry` class exposing `register(codec)`, `unregister(name)`, `get(name)`, and `detect(bytes)`. `register` SHALL index the codec by its `name`, every entry in `extensions`, and every entry in `mimeTypes`; registering a codec whose `name` is already present SHALL replace the prior codec. `detect(bytes)` SHALL return the `name` of the first registered codec whose `canDecode(bytes)` returns `true`, or `undefined` when no codec matches. `get` for an unregistered name SHALL return `undefined`.
|
||||
|
||||
#### Scenario: register then detect round-trips a codec
|
||||
|
||||
- **WHEN** a caller registers a codec and calls `detect` with bytes its `canDecode` accepts
|
||||
- **THEN** `detect` returns that codec's `name` and `get(name)` returns the same codec instance
|
||||
|
||||
#### Scenario: detect returns undefined for unrecognised bytes
|
||||
|
||||
- **WHEN** `detect` is called with bytes no registered codec's `canDecode` accepts
|
||||
- **THEN** it returns `undefined` without throwing
|
||||
|
||||
**Verified by:** `src/registry/registry.test.ts::"register/unregister/get manage codecs by name, extension, and mime type"` AND `src/registry/registry.test.ts::"detect returns the matching codec name or undefined"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AudioCodec plugin contract
|
||||
|
||||
The package SHALL export an `AudioCodec` interface carrying readonly `name`, `extensions`, and `mimeTypes`, plus `canDecode(bytes)`, `decode(bytes)`, `encode(pcm, opts)`, and the optional `createDecoder()` / `createEncoder()` streaming factories. `decode` SHALL return a `DecodeResult` (`{ channelData: Float32Array[]; sampleRate: number; format: string }`). A codec that does not support encoding SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.NOT_SUPPORTED` from `encode`; a codec that does not support streaming SHALL omit the optional factories rather than returning a non-functional stub.
|
||||
|
||||
#### Scenario: encode on a decode-only codec throws AUDIO_CODECS.NOT_SUPPORTED
|
||||
|
||||
- **WHEN** a caller invokes `encode` on a codec whose format is decode-only (e.g. AIFF)
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.NOT_SUPPORTED"` with the format name in `context`
|
||||
|
||||
#### Scenario: decode returns a well-formed DecodeResult
|
||||
|
||||
- **WHEN** a caller invokes `decode` on a registered codec with valid bytes
|
||||
- **THEN** the result has `channelData.length === channels`, a positive `sampleRate`, and a `format` matching the codec's `name`
|
||||
|
||||
**Verified by:** `src/codecs/wav.test.ts::"decode returns a well-formed DecodeResult"` AND `src/codecs/wav.test.ts::"encode throws AUDIO_CODECS.NOT_SUPPORTED for a decode-only format"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Registry decode and encode dispatch
|
||||
|
||||
`CodecRegistry.decode(source, opts?)` SHALL resolve the source to bytes, detect or accept a hinted format, dispatch to the matching codec's `decode`, and return an `AudioDocument` built from the `DecodeResult` plus any `@suaveplan/audio-metadata` tags. `CodecRegistry.encode(doc, format, opts?)` SHALL render the document's PCM, dispatch to the codec registered for `format`, and return the encoded `Uint8Array`. A `decode` whose bytes match no codec and carry no usable hint SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.UNKNOWN_FORMAT`; an `encode` for a `format` with no registered codec SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.NOT_SUPPORTED`.
|
||||
|
||||
#### Scenario: decode dispatches to the detected codec
|
||||
|
||||
- **WHEN** `decode` is called with WAV bytes and no `format` hint
|
||||
- **THEN** detection selects the WAV codec and the returned `AudioDocument` carries the decoded `sampleRate`, `channels`, and `sourceLength`
|
||||
|
||||
#### Scenario: unknown format throws AUDIO_CODECS.UNKNOWN_FORMAT
|
||||
|
||||
- **WHEN** `decode` is called with bytes that match no registered codec and no `format` hint
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.UNKNOWN_FORMAT"`
|
||||
|
||||
**Verified by:** `src/registry/registry.test.ts::"decode dispatches to the detected codec and builds an AudioDocument"` AND `src/registry/registry.test.ts::"decode throws AUDIO_CODECS.UNKNOWN_FORMAT for unrecognised bytes"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal decodeSource entry point
|
||||
|
||||
The package SHALL export `decodeSource(source, opts?)` accepting a `Source` (`string | URL | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>`) and returning a `Promise<AudioDocument>`. It SHALL resolve the source via `detectSource`, detect the format (honouring `opts.format` as a fallback hint), dispatch through the shared `CodecRegistry`, and invoke `opts.notify()` once the first page is ready and `opts.ondata(delta)` per decoded block. An unsupported or non-resolvable source SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.INVALID_SOURCE`.
|
||||
|
||||
#### Scenario: decodeSource accepts every Source variant
|
||||
|
||||
- **WHEN** `decodeSource` is called with a `Uint8Array`, an `ArrayBuffer`, and a `ReadableStream<Uint8Array>` of the same WAV payload
|
||||
- **THEN** all three return an `AudioDocument` whose PCM is byte-equal
|
||||
|
||||
#### Scenario: invalid source throws AUDIO_CODECS.INVALID_SOURCE
|
||||
|
||||
- **WHEN** `decodeSource` is called with a value that is not a recognised `Source` type
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.INVALID_SOURCE"`
|
||||
|
||||
**Verified by:** `src/decode/decode-source.test.ts::"decodeSource accepts buffer, array-buffer, and stream sources"` AND `src/decode/decode-source.test.ts::"decodeSource throws AUDIO_CODECS.INVALID_SOURCE for an unrecognised source"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Universal encodeTo entry point
|
||||
|
||||
The package SHALL export `encodeTo(doc, destination, format, opts?)` accepting a `Destination` (`string` file path, `WritableStream<Uint8Array>`, or `(chunk: Uint8Array) => void` callback) and returning `Promise<void>`. It SHALL encode the document via the shared `CodecRegistry`, then write the bytes to the destination — a `string` path via runtime-gated dynamic `import('node:fs/promises')`, a `WritableStream` via its writer, a callback by invocation. An encode failure SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.ENCODE_FAILED` carrying the underlying error as `cause`.
|
||||
|
||||
#### Scenario: encodeTo writes to each Destination variant
|
||||
|
||||
- **WHEN** `encodeTo` is called with the same document and format against a `WritableStream` and a callback
|
||||
- **THEN** the bytes delivered to both destinations are byte-equal to the registry's `encode` output
|
||||
|
||||
#### Scenario: encode failure throws AUDIO_CODECS.ENCODE_FAILED
|
||||
|
||||
- **WHEN** the selected codec's `encode` throws
|
||||
- **THEN** `encodeTo` throws `AudioCodecsError` whose `code === "AUDIO_CODECS.ENCODE_FAILED"` with the original error in `cause`
|
||||
|
||||
**Verified by:** `src/encode/encode-to.test.ts::"encodeTo writes to a WritableStream and a callback destination"` AND `src/encode/encode-to.test.ts::"encodeTo throws AUDIO_CODECS.ENCODE_FAILED when the codec encode throws"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Format detection via magic bytes
|
||||
|
||||
The package SHALL export `detectSource(source)` returning `{ format, bytes?, path?, reader? }`. It SHALL read at most `HEADER_SIZE` (`256 * 1024`) bytes from the source head and pass them to `@suaveplan/audio-metadata`'s magic-byte detection. For a `string` server file path it SHALL open the file via dynamic `import('node:fs/promises')`, read the header, close the handle, and return `{ format, path }`. For an `http`/`https`/`data`/`blob` string or `URL` it SHALL `fetch()` and return `{ format, reader }`. For a buffer it SHALL detect over the header slice and return `{ format, bytes }`. Detection SHALL never throw for unknown content — it returns `format: undefined`.
|
||||
|
||||
#### Scenario: detectSource sniffs format from the header slice
|
||||
|
||||
- **WHEN** `detectSource` is called with a `Uint8Array` whose first bytes are a known WAV `RIFF` header
|
||||
- **THEN** the result `format` is `"wav"` and `bytes` is the original buffer
|
||||
|
||||
#### Scenario: detectSource returns undefined format for unknown content
|
||||
|
||||
- **WHEN** `detectSource` is called with bytes matching no known magic signature
|
||||
- **THEN** the result `format` is `undefined` and no error is thrown
|
||||
|
||||
**Verified by:** `src/detect/detect-format.test.ts::"detectSource sniffs a known format from the header slice"` AND `src/detect/detect-format.test.ts::"detectSource returns undefined format for unrecognised content"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Progressive streaming decode
|
||||
|
||||
The package SHALL export `progressiveDecode(source, opts, registry)` that fills `AudioDocument.pages` as codec chunks arrive. It SHALL obtain a `StreamingDecoder` via the codec's `createDecoder()` when available, feed each source chunk through `decoder.write(chunk)`, drain `decoder.flush()` at end-of-stream, and accumulate decoded PCM into pages via a `pageAccumulator`. The returned `AudioDocument` SHALL be usable immediately — before decode completes — and edits chained onto it SHALL apply to the partial page set and extend automatically as further pages arrive. When the codec exposes no `createDecoder()`, `progressiveDecode` SHALL fall back to a single full-buffer `decode`.
|
||||
|
||||
#### Scenario: document is usable before decode completes
|
||||
|
||||
- **WHEN** a caller obtains the `AudioDocument` from `progressiveDecode` and runs a `trim` edit before the source stream is drained
|
||||
- **THEN** the edit is appended, `version` increments, and the compiled length tracks the page count as more pages arrive
|
||||
|
||||
#### Scenario: streaming decode total equals non-streaming decode total
|
||||
|
||||
- **WHEN** the same payload is decoded via `progressiveDecode` and via a single `CodecRegistry.decode`
|
||||
- **THEN** the two documents' `sourceLength` and concatenated PCM are equal
|
||||
|
||||
**Verified by:** `src/stream/stream-decoder.test.ts::"returned document is editable before the source stream is drained"` AND `tests/property.test.ts::"streaming decode yields the same total samples as non-streaming decode"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Streaming decode and encode transforms
|
||||
|
||||
`CodecRegistry.decodeStream(source, format)` SHALL return a `ReadableStream<AudioPage>` driven by the codec's `StreamingDecoder`, emitting one `AudioPage` per non-empty `DecodeResult`. `CodecRegistry.encodeStream(doc, format)` SHALL return a `ReadableStream<Uint8Array>` driven by the codec's `StreamingEncoder`. The package SHALL also export `createDecodeTransform(format)` returning a `@suaveplan/streams` `TransformStream<Uint8Array, AudioPage>` and `createEncodeTransform(format)` returning a `TransformStream<Float32Array[], Uint8Array>`, so codec stages compose into `@suaveplan/streams` pipelines with native backpressure. A streaming operation on a codec lacking the corresponding factory SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.STREAM_ERROR`.
|
||||
|
||||
#### Scenario: decodeStream emits one page per decoded chunk
|
||||
|
||||
- **WHEN** a caller drains `decodeStream` over a chunked WAV source
|
||||
- **THEN** the concatenated `AudioPage` PCM equals the non-streaming `decode` output
|
||||
|
||||
#### Scenario: streaming on a non-streaming codec throws AUDIO_CODECS.STREAM_ERROR
|
||||
|
||||
- **WHEN** `decodeStream` is called for a codec that exposes no `createDecoder()`
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.STREAM_ERROR"`
|
||||
|
||||
**Verified by:** `src/stream/stream-decoder.test.ts::"decodeStream emits one AudioPage per decoded chunk"` AND `src/stream/stream-encoder.test.ts::"createEncodeTransform composes into a @suaveplan/streams pipeline"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Native WAV codec
|
||||
|
||||
The default registry SHALL ship a native `WavCodec` requiring no external dependency, supporting decode, encode, and streaming. It SHALL parse the RIFF/`fmt`/`data` chunk structure, support 8/16/24/32-bit PCM and 32-bit float sample formats, and produce a `DecodeResult` whose `channelData` is normalised `Float32Array[]`. `encode` SHALL emit a spec-conformant RIFF WAV from `Float32Array[]` PCM. Malformed or truncated WAV bytes SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.DECODE_FAILED`.
|
||||
|
||||
#### Scenario: WAV decode-then-encode round-trips PCM exactly
|
||||
|
||||
- **WHEN** a caller decodes a 16-bit WAV then re-encodes the resulting `AudioDocument` as WAV
|
||||
- **THEN** the re-decoded PCM is byte-equal to the original decoded PCM
|
||||
|
||||
#### Scenario: truncated WAV throws AUDIO_CODECS.DECODE_FAILED
|
||||
|
||||
- **WHEN** `WavCodec.decode` is given a buffer whose `data` chunk length exceeds the available bytes
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.DECODE_FAILED"`
|
||||
|
||||
**Verified by:** `src/codecs/wav.test.ts::"WAV decode-then-encode round-trips PCM exactly"` AND `src/codecs/wav.test.ts::"truncated WAV bytes throw AUDIO_CODECS.DECODE_FAILED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Native AIFF and CAF decoders
|
||||
|
||||
The default registry SHALL ship native `AiffCodec` and `CafCodec` decoders requiring no external dependency. Both SHALL implement `canDecode` and `decode`, parse their respective container chunk structures, and produce a `DecodeResult` of normalised `Float32Array[]` PCM. Neither supports encoding or streaming: `encode` SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.NOT_SUPPORTED` and the optional streaming factories SHALL be omitted.
|
||||
|
||||
#### Scenario: AIFF decodes to normalised float PCM
|
||||
|
||||
- **WHEN** `AiffCodec.decode` is given a valid AIFF `FORM`/`COMM`/`SSND` buffer
|
||||
- **THEN** the result `channelData` is `Float32Array[]` with samples in `[-1, 1]` and `format === "aiff"`
|
||||
|
||||
#### Scenario: CAF encode throws AUDIO_CODECS.NOT_SUPPORTED
|
||||
|
||||
- **WHEN** a caller invokes `CafCodec.encode`
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.NOT_SUPPORTED"`
|
||||
|
||||
**Verified by:** `src/codecs/aiff.test.ts::"AIFF decodes to normalised float PCM"` AND `src/codecs/caf.test.ts::"CAF encode throws AUDIO_CODECS.NOT_SUPPORTED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: WASM-backed codecs loaded on demand
|
||||
|
||||
The default registry SHALL ship `Mp3Codec`, `FlacCodec`, `OggVorbisCodec`, `OpusCodec`, `AacCodec`, `WebMCodec`, and `QoaCodec`, each wrapping a WASM module loaded via dynamic `import()` of an `optionalDependency` (e.g. `@audio/decode-mp3`, `@audio/decode-flac`). The WASM module SHALL be loaded lazily on first `decode`/`encode`/`createDecoder` call, not at package import time. When the optional dependency is not installed, the codec's first use SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.NOT_SUPPORTED` naming the missing module. MP3, FLAC, Opus, and QOA SHALL support encode; Ogg Vorbis, AAC, and WebM are decode-only.
|
||||
|
||||
#### Scenario: WASM module loads lazily on first use
|
||||
|
||||
- **WHEN** the package is imported but no WASM codec method is called
|
||||
- **THEN** no `@audio/*` dynamic `import()` has been evaluated
|
||||
|
||||
#### Scenario: missing optional dependency throws AUDIO_CODECS.NOT_SUPPORTED
|
||||
|
||||
- **WHEN** `Mp3Codec.decode` is called and `@audio/decode-mp3` is not installed
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.NOT_SUPPORTED"` naming the missing module
|
||||
|
||||
**Verified by:** `src/codecs/mp3.test.ts::"WASM module is not loaded until first decode call"` AND `src/codecs/mp3.test.ts::"missing @audio/decode-mp3 throws AUDIO_CODECS.NOT_SUPPORTED"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Memory budget enforcement
|
||||
|
||||
`decodeSource` and `progressiveDecode` SHALL accept an optional `budget` (bytes) in `DecodeSourceOpts`. Before allocating pages they SHALL project required memory as `sampleRate × channels × durationSamples × 4` and, when the projection exceeds `budget`, abort decode and throw `AudioCodecsError` with code `AUDIO_CODECS.BUDGET_EXCEEDED` carrying the projected and budgeted byte counts in `context`. When `budget` is omitted no projection check SHALL run.
|
||||
|
||||
#### Scenario: projected allocation over budget aborts decode
|
||||
|
||||
- **WHEN** `decodeSource` is called with a `budget` smaller than the projected decoded-PCM size
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.BUDGET_EXCEEDED"` with `projectedBytes` and `budgetBytes` in `context`, before any page is allocated
|
||||
|
||||
#### Scenario: decode within budget proceeds normally
|
||||
|
||||
- **WHEN** `decodeSource` is called with a `budget` larger than the projected size
|
||||
- **THEN** decode completes and returns a fully-populated `AudioDocument`
|
||||
|
||||
**Verified by:** `src/decode/decode-source.test.ts::"decodeSource throws AUDIO_CODECS.BUDGET_EXCEEDED when projection exceeds budget"` AND `src/decode/decode-source.test.ts::"decodeSource completes when projection is within budget"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Source resolution across runtimes
|
||||
|
||||
`detectSource` and `decodeSource` SHALL resolve a `string` or `URL` source by these rules: a string starting with `http`/`https`/`data`/`blob`, or any `URL`, resolves via `fetch()`; a string starting with `file:` resolves via dynamic `import('node:fs/promises')`; any other bare string resolves via `fs/promises.readFile` when running on a server runtime and via `fetch()` when running in a browser. `ArrayBuffer`, `Uint8Array`, and `ReadableStream<Uint8Array>` sources SHALL be consumed directly with no platform call. The `node:fs/promises` import SHALL only ever appear inside the runtime-gated branch.
|
||||
|
||||
#### Scenario: http string resolves via fetch
|
||||
|
||||
- **WHEN** `detectSource` is called with `"https://example.test/audio.wav"`
|
||||
- **THEN** the source is fetched and a `reader` is returned, with no `node:fs` import evaluated
|
||||
|
||||
#### Scenario: file: string resolves via fs in a server runtime
|
||||
|
||||
- **WHEN** `detectSource` is called with `"file:///tmp/audio.wav"` on a server runtime
|
||||
- **THEN** `node:fs/promises` is dynamically imported and the header is read from disk
|
||||
|
||||
**Verified by:** `src/detect/detect-format.test.ts::"http and URL sources resolve via fetch"` AND `src/detect/detect-format.test.ts::"file: sources resolve via dynamically-imported node:fs"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: AbortSignal propagation
|
||||
|
||||
`decodeSource`, `encodeTo`, `progressiveDecode`, and the streaming methods (`CodecRegistry.decodeStream`, `CodecRegistry.encodeStream`) SHALL accept an `AbortSignal` via their options object. An already-aborted signal SHALL cause the method to throw `AudioCodecsError` with code `AUDIO_CODECS.STREAM_ERROR` before any source read or page allocation. A signal aborted mid-decode SHALL terminate at the next chunk boundary with the same error, release any open file handle or stream reader, and discard partial pages.
|
||||
|
||||
#### Scenario: pre-aborted signal short-circuits decode
|
||||
|
||||
- **WHEN** `decodeSource` is called with an already-aborted signal
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.STREAM_ERROR"` before any source byte is read
|
||||
|
||||
#### Scenario: mid-decode abort releases the reader
|
||||
|
||||
- **WHEN** a signal is aborted after the first chunk of a `progressiveDecode` stream is consumed
|
||||
- **THEN** decode terminates at the next chunk boundary, the stream reader is released, and `AUDIO_CODECS.STREAM_ERROR` is thrown
|
||||
|
||||
**Verified by:** `src/decode/decode-source.test.ts::"decodeSource throws AUDIO_CODECS.STREAM_ERROR for a pre-aborted signal"` AND `src/stream/stream-decoder.test.ts::"progressiveDecode honours mid-stream abort and releases the reader"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Zod boundary validation
|
||||
|
||||
Every public options object — `DecodeOpts`, `EncodeOpts`, `DecodeSourceOpts`, `AudioCodec`, `StreamingDecoder`, `StreamingEncoder` — SHALL have a Zod schema exported from a sibling `*.schemas.ts`, and every public entry point (`decodeSource`, `encodeTo`, `CodecRegistry.register`, `CodecRegistry.decode`, `CodecRegistry.encode`) SHALL validate its options or codec argument against the schema before use. A validation failure SHALL throw `AudioCodecsError` with code `AUDIO_CODECS.INVALID_SOURCE` carrying the Zod issue list in `context`.
|
||||
|
||||
#### Scenario: invalid options are rejected at the boundary
|
||||
|
||||
- **WHEN** `decodeSource` is called with a `budget` that is a negative number
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.INVALID_SOURCE"` with the Zod issues in `context`
|
||||
|
||||
#### Scenario: malformed codec is rejected at registration
|
||||
|
||||
- **WHEN** `CodecRegistry.register` is called with an object missing a `name`
|
||||
- **THEN** it throws `AudioCodecsError` whose `code === "AUDIO_CODECS.INVALID_SOURCE"` with the Zod issues in `context`
|
||||
|
||||
**Verified by:** `src/decode/decode-source.test.ts::"decodeSource rejects invalid options with AUDIO_CODECS.INVALID_SOURCE"` AND `src/registry/registry.test.ts::"register rejects a malformed codec via Zod validation"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Structured error codes
|
||||
|
||||
The package SHALL export `AudioCodecsErrorCode`, `AUDIO_CODECS_ERROR_METADATA`, and `registerAudioCodecsErrorCodes()`, registering the codes `AUDIO_CODECS.UNKNOWN_FORMAT`, `AUDIO_CODECS.DECODE_FAILED`, `AUDIO_CODECS.ENCODE_FAILED`, `AUDIO_CODECS.STREAM_ERROR`, `AUDIO_CODECS.NOT_SUPPORTED`, `AUDIO_CODECS.INVALID_SOURCE`, and `AUDIO_CODECS.BUDGET_EXCEEDED` with `@suaveplan/error`. Every error thrown by the package SHALL be an `AudioCodecsError` extending `SuaveplanError` and carry one of these codes, `cause`, and `context`. Every registered code SHALL be reachable from a real call site exercised by tests.
|
||||
|
||||
#### Scenario: every registered code is thrown by a call site
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** each code in `AUDIO_CODECS_ERROR_METADATA` is asserted thrown by at least one test
|
||||
|
||||
**Verified by:** `src/error-codes.test.ts::"every AUDIO_CODEC code is reachable from a call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
The package SHALL instrument its public entry points via `@opentelemetry/api`: spans `codec.decode`, `codec.encode`, and `codec.detect`; counters `audio_codec_decode_total` and `audio_codec_encode_total`; histograms `audio_codec_decode_duration_ms` and `audio_codec_encode_duration_ms`. Spans SHALL carry the resolved `format` as an attribute. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect.
|
||||
|
||||
#### Scenario: instrumentation is inert without an exporter
|
||||
|
||||
- **WHEN** the package is imported and used with no OTel exporter registered
|
||||
- **THEN** no error is thrown and no global state is mutated at import time
|
||||
|
||||
#### Scenario: a decode records a span and histogram
|
||||
|
||||
- **WHEN** `decodeSource` runs against a real in-memory `MeterProvider` and `TracerProvider`
|
||||
- **THEN** `codec.decode` records one span carrying the `format` attribute, `audio_codec_decode_total` increments by 1, and `audio_codec_decode_duration_ms` records one observation
|
||||
|
||||
**Verified by:** `src/registry/registry.test.ts::"decode records a span, counter, and histogram when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Subpath exports per feature module
|
||||
|
||||
`package.json` SHALL declare one `exports` entry per feature module (`./registry/registry`, `./decode/decode-source`, `./encode/encode-to`, `./detect/detect-format`, `./stream/stream-decoder`, `./stream/stream-encoder`, `./codecs/wav`, `./codecs/mp3`, `./codecs/flac`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`.
|
||||
|
||||
#### Scenario: each feature module is independently importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/audio-codecs/codecs/wav`
|
||||
- **THEN** the import resolves to the WAV codec module without pulling the whole package barrel or any WASM codec
|
||||
|
||||
**Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"`
|
||||
|
||||
<!-- markdownlint-enable MD024 -->
|
||||
Reference in New Issue
Block a user