25 KiB
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, orBuffer—node:fs/promisesappears only inside a dynamicimport()expression
Scenario: Tests pass under both runtimes
- WHEN the suite runs under
bun test(server) and under vitest with ahappy-domshim (browser) - THEN both runs pass with identical results, with
fetch-backed source resolution exercised in the browser run andfs-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
detectwith bytes itscanDecodeaccepts - THEN
detectreturns that codec'snameandget(name)returns the same codec instance
Scenario: detect returns undefined for unrecognised bytes
- WHEN
detectis called with bytes no registered codec'scanDecodeaccepts - THEN it returns
undefinedwithout 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
encodeon a codec whose format is decode-only (e.g. AIFF) - THEN it throws
AudioCodecsErrorwhosecode === "AUDIO_CODECS.NOT_SUPPORTED"with the format name incontext
Scenario: decode returns a well-formed DecodeResult
- WHEN a caller invokes
decodeon a registered codec with valid bytes - THEN the result has
channelData.length === channels, a positivesampleRate, and aformatmatching the codec'sname
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
decodeis called with WAV bytes and noformathint - THEN detection selects the WAV codec and the returned
AudioDocumentcarries the decodedsampleRate,channels, andsourceLength
Scenario: unknown format throws AUDIO_CODECS.UNKNOWN_FORMAT
- WHEN
decodeis called with bytes that match no registered codec and noformathint - THEN it throws
AudioCodecsErrorwhosecode === "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
decodeSourceis called with aUint8Array, anArrayBuffer, and aReadableStream<Uint8Array>of the same WAV payload - THEN all three return an
AudioDocumentwhose PCM is byte-equal
Scenario: invalid source throws AUDIO_CODECS.INVALID_SOURCE
- WHEN
decodeSourceis called with a value that is not a recognisedSourcetype - THEN it throws
AudioCodecsErrorwhosecode === "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
encodeTois called with the same document and format against aWritableStreamand a callback - THEN the bytes delivered to both destinations are byte-equal to the registry's
encodeoutput
Scenario: encode failure throws AUDIO_CODECS.ENCODE_FAILED
- WHEN the selected codec's
encodethrows - THEN
encodeTothrowsAudioCodecsErrorwhosecode === "AUDIO_CODECS.ENCODE_FAILED"with the original error incause
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
detectSourceis called with aUint8Arraywhose first bytes are a known WAVRIFFheader - THEN the result
formatis"wav"andbytesis the original buffer
Scenario: detectSource returns undefined format for unknown content
- WHEN
detectSourceis called with bytes matching no known magic signature - THEN the result
formatisundefinedand 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
AudioDocumentfromprogressiveDecodeand runs atrimedit before the source stream is drained - THEN the edit is appended,
versionincrements, 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
progressiveDecodeand via a singleCodecRegistry.decode - THEN the two documents'
sourceLengthand 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
decodeStreamover a chunked WAV source - THEN the concatenated
AudioPagePCM equals the non-streamingdecodeoutput
Scenario: streaming on a non-streaming codec throws AUDIO_CODECS.STREAM_ERROR
- WHEN
decodeStreamis called for a codec that exposes nocreateDecoder() - THEN it throws
AudioCodecsErrorwhosecode === "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
AudioDocumentas WAV - THEN the re-decoded PCM is byte-equal to the original decoded PCM
Scenario: truncated WAV throws AUDIO_CODECS.DECODE_FAILED
- WHEN
WavCodec.decodeis given a buffer whosedatachunk length exceeds the available bytes - THEN it throws
AudioCodecsErrorwhosecode === "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.decodeis given a valid AIFFFORM/COMM/SSNDbuffer - THEN the result
channelDataisFloat32Array[]with samples in[-1, 1]andformat === "aiff"
Scenario: CAF encode throws AUDIO_CODECS.NOT_SUPPORTED
- WHEN a caller invokes
CafCodec.encode - THEN it throws
AudioCodecsErrorwhosecode === "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/*dynamicimport()has been evaluated
Scenario: missing optional dependency throws AUDIO_CODECS.NOT_SUPPORTED
- WHEN
Mp3Codec.decodeis called and@audio/decode-mp3is not installed - THEN it throws
AudioCodecsErrorwhosecode === "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
decodeSourceis called with abudgetsmaller than the projected decoded-PCM size - THEN it throws
AudioCodecsErrorwhosecode === "AUDIO_CODECS.BUDGET_EXCEEDED"withprojectedBytesandbudgetBytesincontext, before any page is allocated
Scenario: decode within budget proceeds normally
- WHEN
decodeSourceis called with abudgetlarger 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
detectSourceis called with"https://example.test/audio.wav" - THEN the source is fetched and a
readeris returned, with nonode:fsimport evaluated
Scenario: file: string resolves via fs in a server runtime
- WHEN
detectSourceis called with"file:///tmp/audio.wav"on a server runtime - THEN
node:fs/promisesis 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
decodeSourceis called with an already-aborted signal - THEN it throws
AudioCodecsErrorwhosecode === "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
progressiveDecodestream is consumed - THEN decode terminates at the next chunk boundary, the stream reader is released, and
AUDIO_CODECS.STREAM_ERRORis 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
decodeSourceis called with abudgetthat is a negative number - THEN it throws
AudioCodecsErrorwhosecode === "AUDIO_CODECS.INVALID_SOURCE"with the Zod issues incontext
Scenario: malformed codec is rejected at registration
- WHEN
CodecRegistry.registeris called with an object missing aname - THEN it throws
AudioCodecsErrorwhosecode === "AUDIO_CODECS.INVALID_SOURCE"with the Zod issues incontext
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_METADATAis 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
decodeSourceruns against a real in-memoryMeterProviderandTracerProvider - THEN
codec.decoderecords one span carrying theformatattribute,audio_codec_decode_totalincrements by 1, andaudio_codec_decode_duration_msrecords 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"