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

343 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 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 -->