Files
2026-05-27 23:29:53 +01:00

25 KiB
Raw Permalink Blame History

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"