Initial Commit
This commit is contained in:
274
openspec/changes/enterprise-audio-codecs/design.md
Normal file
274
openspec/changes/enterprise-audio-codecs/design.md
Normal file
@@ -0,0 +1,274 @@
|
||||
# Design: enterprise-audio-codecs
|
||||
|
||||
**Change ID**: enterprise-audio-codecs
|
||||
**Package**: `@suaveplan/audio-codecs`
|
||||
|
||||
`@suaveplan/audio-codecs` is the universal format decode/encode layer of the `@suaveplan/audio-*`
|
||||
family. It turns any audio source — file path, URL, buffer, or byte stream — into an
|
||||
`AudioDocument`, and turns an `AudioDocument` back into encoded bytes in any supported format. This
|
||||
document carries the **package-specific design** for the codec layer.
|
||||
|
||||
---
|
||||
|
||||
## 1. Relationship to the ecosystem architecture
|
||||
|
||||
The cross-cutting `@suaveplan/audio-*` architecture — the three-layer separation (data model /
|
||||
timeline-transport / rendering engine), the six plugin slots, the "zero duplication of genesis
|
||||
primitives" rule, and the universal-tier purity rule — lives in
|
||||
`enterprise-audio-core/design.md` §1–§4. It is **referenced here, not duplicated**.
|
||||
|
||||
`audio-codecs` occupies **plugin slot 4 (Codec)** from that architecture: it decodes / encodes a
|
||||
specific format and registers codecs in a `CodecRegistry`. It sits directly above `audio-core` in
|
||||
the dependency graph and below every rendering engine and CLI. Its output is the universal
|
||||
`AudioDocument` defined by `audio-core` — the codec layer never owns its own audio container.
|
||||
|
||||
The `Segment` shape referenced anywhere in this package is the single family-wide definition from
|
||||
`enterprise-audio-core/design.md` §2.2: `{ from, count, to, rate?, ref?, interp? }`. Codecs do not
|
||||
introduce segment variants — they produce `AudioPage` PCM, and editing/segmentation is `audio-core`'s
|
||||
job.
|
||||
|
||||
---
|
||||
|
||||
## 2. Decode model
|
||||
|
||||
### 2.1. Source resolution
|
||||
|
||||
A `Source` is one of `string | URL | ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>`.
|
||||
Resolution rules:
|
||||
|
||||
- `string` starting with `http`/`https`/`data`/`blob`, or any `URL` → `fetch()`.
|
||||
- `string` starting with `file:` → dynamic `import('node:fs/promises')`.
|
||||
- other bare `string` → `fs/promises.readFile` on a server runtime, `fetch()` in a browser.
|
||||
- `ArrayBuffer` / `Uint8Array` / `ReadableStream<Uint8Array>` → consumed directly, no platform call.
|
||||
|
||||
The `node:fs/promises` import is **always** inside the runtime-gated branch — never a top-level
|
||||
import — so the package stays universal-tier per `enterprise-audio-core/design.md` §1.5.
|
||||
|
||||
### 2.2. Format detection
|
||||
|
||||
```ts
|
||||
const HEADER_SIZE = 256 * 1024
|
||||
|
||||
async function detectSource(source: Source) {
|
||||
if (typeof source === 'string' && !source.startsWith('http')) {
|
||||
const { open } = await import('node:fs/promises')
|
||||
const fh = await open(source, 'r')
|
||||
const hdr = new Uint8Array(HEADER_SIZE)
|
||||
await fh.read(hdr, 0, HEADER_SIZE, 0)
|
||||
await fh.close()
|
||||
return { format: detectFormat(hdr), path: source }
|
||||
}
|
||||
const bytes = await resolveToUint8Array(source)
|
||||
return { format: detectFormat(bytes.subarray(0, HEADER_SIZE)), bytes }
|
||||
}
|
||||
```
|
||||
|
||||
`detectFormat` delegates to `@suaveplan/audio-metadata`'s magic-byte sniffing. Detection never
|
||||
throws for unknown content — it returns `format: undefined`, and the caller falls back to an
|
||||
explicit `opts.format` hint or throws `AUDIO_CODECS.UNKNOWN_FORMAT`. Reading only the first 256 KiB
|
||||
keeps detection cheap even for multi-gigabyte files.
|
||||
|
||||
### 2.3. Progressive streaming decode
|
||||
|
||||
The decoder fills pages progressively so the document is editable before decode completes:
|
||||
|
||||
```ts
|
||||
async function progressiveDecode(source, opts, registry) {
|
||||
const { format, reader, bytes } = await detectSource(source)
|
||||
const codec = registry.get(format ?? opts.format)
|
||||
const dec = codec.createDecoder?.() ?? null
|
||||
|
||||
const acc = pageAccumulator({
|
||||
pages: opts.pages ?? [],
|
||||
notify: opts.notify,
|
||||
ondata: opts.ondata,
|
||||
})
|
||||
|
||||
if (reader) {
|
||||
for await (const chunk of reader) {
|
||||
const result = dec ? await dec.write(chunk) : await codec.decode(chunk)
|
||||
if (result?.channelData.length) acc.push(result.channelData, result.sampleRate)
|
||||
}
|
||||
if (dec) {
|
||||
const flushed = await dec.flush()
|
||||
if (flushed?.channelData.length) acc.push(flushed.channelData, flushed.sampleRate)
|
||||
}
|
||||
}
|
||||
|
||||
const final = acc.done()
|
||||
return createDocument(acc.pages, acc.sampleRate, acc.channels, final.length, opts, final.stats)
|
||||
}
|
||||
```
|
||||
|
||||
**Key property:** the returned `AudioDocument` is usable immediately. Because `audio-core` edits are
|
||||
metadata (`enterprise-audio-core/design.md` §2.1), an edit chained onto the partial document applies
|
||||
to whatever pages exist and extends automatically as the accumulator pushes more pages. `notify()`
|
||||
fires when the first page is ready; `ondata(delta)` fires per decoded block with running stats.
|
||||
|
||||
### 2.4. Memory budget
|
||||
|
||||
When `opts.budget` is set, `decodeSource` / `progressiveDecode` project required memory as
|
||||
`sampleRate × channels × durationSamples × 4` (bytes per `Float32` sample) and throw
|
||||
`AUDIO_CODECS.BUDGET_EXCEEDED` before allocating any page when the projection exceeds the budget. The
|
||||
projected and budgeted byte counts go into `error.context` so the caller can size a retry.
|
||||
|
||||
---
|
||||
|
||||
## 3. Encode model
|
||||
|
||||
`encodeTo(doc, destination, format, opts?)` renders the document's PCM through `audio-core`, encodes
|
||||
it via the codec registered for `format`, then writes the bytes to the `Destination`:
|
||||
|
||||
- `string` file path → dynamic `import('node:fs/promises')`, `writeFile`.
|
||||
- `WritableStream<Uint8Array>` → obtain a writer, write, close.
|
||||
- `(chunk: Uint8Array) => void` callback → invoke per chunk (single chunk for non-streaming codecs).
|
||||
|
||||
Streaming encode is exposed two ways: `CodecRegistry.encodeStream(doc, format)` returns a
|
||||
`ReadableStream<Uint8Array>`, and `createEncodeTransform(format)` returns a `@suaveplan/streams`
|
||||
`TransformStream<Float32Array[], Uint8Array>` so a codec stage drops into a stream pipeline with
|
||||
native backpressure.
|
||||
|
||||
---
|
||||
|
||||
## 4. Codec plugin contract
|
||||
|
||||
Every codec implements `AudioCodec`:
|
||||
|
||||
```ts
|
||||
interface AudioCodec {
|
||||
readonly name: string
|
||||
readonly extensions: string[]
|
||||
readonly mimeTypes: string[]
|
||||
canDecode(bytes: Uint8Array): boolean
|
||||
decode(bytes: Uint8Array | ArrayBuffer): Promise<DecodeResult>
|
||||
encode(pcm: Float32Array[], opts: EncodeOpts): Promise<Uint8Array>
|
||||
createDecoder?(): StreamingDecoder
|
||||
createEncoder?(): StreamingEncoder
|
||||
}
|
||||
```
|
||||
|
||||
The `CodecRegistry` indexes each codec by `name`, every `extension`, and every `mimeType`.
|
||||
`detect(bytes)` returns the first codec whose `canDecode` accepts the header. Codecs that do not
|
||||
support encoding throw `AUDIO_CODECS.NOT_SUPPORTED` from `encode`; codecs that do not support
|
||||
streaming **omit** `createDecoder`/`createEncoder` rather than ship a non-functional stub — the
|
||||
registry checks for the factory's presence before offering a streaming path.
|
||||
|
||||
### 4.1. Built-in codecs
|
||||
|
||||
| Format | Decode | Encode | Streaming | Backing |
|
||||
|---|---|---|---|---|
|
||||
| WAV | yes | yes | yes | native, no deps |
|
||||
| AIFF | yes | no | no | native, no deps |
|
||||
| CAF | yes | no | no | native, no deps |
|
||||
| MP3 | yes | yes | yes | WASM `@audio/decode-mp3` |
|
||||
| FLAC | yes | yes | yes | WASM `@audio/decode-flac` |
|
||||
| Ogg Vorbis | yes | no | yes | WASM |
|
||||
| Opus | yes | yes | yes | WASM |
|
||||
| AAC | yes | no | no | WASM |
|
||||
| WebM | yes | no | no | WASM |
|
||||
| QOA | yes | yes | yes | WASM |
|
||||
|
||||
Native codecs (WAV/AIFF/CAF) parse their container chunk structures directly and require zero
|
||||
external dependencies. WASM codecs wrap a WebAssembly module loaded via dynamic `import()` of an
|
||||
`optionalDependency`, so consumers install only the formats they use. The WASM module loads lazily
|
||||
on first `decode`/`encode`/`createDecoder` — never at package import time — and a missing optional
|
||||
dependency throws `AUDIO_CODECS.NOT_SUPPORTED` naming the absent module.
|
||||
|
||||
### 4.2. `@suaveplan/streams` integration
|
||||
|
||||
```ts
|
||||
import { TransformStream } from '@suaveplan/streams'
|
||||
|
||||
function createDecodeTransform(format: string): TransformStream<Uint8Array, AudioPage> {
|
||||
const codec = registry.get(format)
|
||||
const dec = codec.createDecoder()!
|
||||
return new TransformStream({
|
||||
async transform(chunk, controller) {
|
||||
const result = await dec.write(chunk)
|
||||
if (result?.channelData.length) controller.enqueue(createPage(result.channelData))
|
||||
},
|
||||
async flush(controller) {
|
||||
const result = await dec.flush()
|
||||
if (result?.channelData.length) controller.enqueue(createPage(result.channelData))
|
||||
},
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Error hierarchy
|
||||
|
||||
`audio-codecs` registers its own `AUDIO_CODEC_*` namespace with `@suaveplan/error`, parallel to
|
||||
`audio-core`'s `AUDIO_CORE_*` namespace:
|
||||
|
||||
```ts
|
||||
defineCodes('AUDIO_CODEC', [
|
||||
{ code: 'AUDIO_CODECS.UNKNOWN_FORMAT', severity: 'error', retryable: false },
|
||||
{ code: 'AUDIO_CODECS.DECODE_FAILED', severity: 'error', retryable: false },
|
||||
{ code: 'AUDIO_CODECS.ENCODE_FAILED', severity: 'error', retryable: false },
|
||||
{ code: 'AUDIO_CODECS.STREAM_ERROR', severity: 'warning', retryable: true },
|
||||
{ code: 'AUDIO_CODECS.NOT_SUPPORTED', severity: 'error', retryable: false },
|
||||
{ code: 'AUDIO_CODECS.INVALID_SOURCE', severity: 'error', retryable: false },
|
||||
{ code: 'AUDIO_CODECS.BUDGET_EXCEEDED', severity: 'error', retryable: false },
|
||||
])
|
||||
```
|
||||
|
||||
Every code is reachable from a real call site exercised by tests — `error-codes.test.ts` asserts
|
||||
each metadata entry is thrown somewhere.
|
||||
|
||||
---
|
||||
|
||||
## 6. File layout
|
||||
|
||||
```text
|
||||
packages/media/audio/audio-codecs/
|
||||
package.json, tsconfig.json, vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
registry/ registry.ts .md .types.ts .test.ts index.ts
|
||||
decode/ decode-source.ts .md .types.ts .test.ts index.ts
|
||||
encode/ encode-to.ts .md .types.ts .test.ts index.ts
|
||||
detect/ detect-format.ts .md .test.ts index.ts
|
||||
stream/ stream-decoder.ts (+ .md .test.ts) · stream-encoder.ts (+ .md .test.ts) · index.ts
|
||||
codecs/ wav.ts (+ .md .test.ts) · aiff.ts · caf.ts · mp3.ts (+ .md .test.ts) · flac.ts (+ .md .test.ts) · ogg-vorbis.ts · opus.ts · aac.ts · webm.ts · qoa.ts · index.ts
|
||||
__benchmarks__/ decode.bench.ts encode.bench.ts stream-decode.bench.ts
|
||||
tests/
|
||||
integration.test.ts property.test.ts purity.test.ts exports.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Benchmarks
|
||||
|
||||
- `decode.bench.ts` — decode a 5-minute stereo WAV (native) and a 5-minute MP3 (WASM).
|
||||
- `encode.bench.ts` — encode 5 minutes of stereo PCM to WAV and to FLAC.
|
||||
- `stream-decode.bench.ts` — progressive stream decode of a 5-minute MP3, measuring time-to-first-page.
|
||||
|
||||
---
|
||||
|
||||
## 8. Decisions and trade-offs
|
||||
|
||||
- **Codecs are plugins, not a fixed enum.** A `CodecRegistry` rather than a hardcoded `switch`
|
||||
means consumers can register custom or proprietary codecs, and the built-in set can shrink to just
|
||||
the native WAV/AIFF/CAF codecs for a minimal install. This matches plugin slot 4 in the ecosystem
|
||||
architecture.
|
||||
- **WASM codecs are `optionalDependencies`.** Bundling every WASM decoder would bloat every install
|
||||
with formats most consumers never touch. Declaring them optional and loading via dynamic `import()`
|
||||
keeps the base package small and lets the dependency graph stay universal-tier — the import is
|
||||
lazy and runtime-gated, so a browser build that never decodes FLAC never pulls the FLAC WASM.
|
||||
- **`node:fs` is dynamic-import-only.** The package must run in browsers. Resolving a file path is
|
||||
the one server-only operation, so it lives behind a runtime-gated `import('node:fs/promises')`
|
||||
rather than a top-level import — preserving the universal-tier purity rule from
|
||||
`enterprise-audio-core/design.md` §1.5.
|
||||
- **Progressive decode returns the document early.** Because `audio-core` edits are metadata, the
|
||||
document can be handed back before decode finishes and edits chained onto it extend as pages
|
||||
arrive. This is the whole reason the codec layer produces `AudioPage` PCM rather than a monolithic
|
||||
buffer — it lets a UI start editing a long file the instant the first page lands.
|
||||
- **Memory budget is opt-in.** Projecting and checking allocation size on every decode would add
|
||||
overhead to the common small-file case. `budget` is opt-in for callers decoding untrusted or
|
||||
unbounded sources, where an OOM would otherwise crash the host.
|
||||
- **The architecture doc is not re-stated here.** Per the same convention `audio-core` uses, the
|
||||
cross-cutting model lives once in `enterprise-audio-core/design.md` §1–§4 and sibling proposals
|
||||
reference it. Duplicating it would create drift.
|
||||
304
openspec/changes/enterprise-audio-codecs/proposal.md
Normal file
304
openspec/changes/enterprise-audio-codecs/proposal.md
Normal file
@@ -0,0 +1,304 @@
|
||||
# OpenSpec Change: enterprise-audio-codecs
|
||||
|
||||
- **Change ID**: enterprise-audio-codecs
|
||||
- **Package**: `@suaveplan/audio-codecs`
|
||||
- **Category**: universal
|
||||
- **Target path**: `packages/media/audio/audio-codecs/`
|
||||
- **Status**: PROPOSED
|
||||
- **Date**: 2026-05-14
|
||||
- **Design**: `./design.md`
|
||||
- **Spec delta**: `./specs/audio-codecs/spec.md`
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Deliver `@suaveplan/audio-codecs` on the genesis branch from scratch at enterprise quality. The package provides a universal format decode/encode registry for the genesis monorepo: a single entry point for loading audio from any source (file, URL, buffer, stream) and saving to any supported format. Codecs are registered as plugins and loaded on demand via dynamic `import()`. Format detection uses `@suaveplan/audio-metadata`'s magic-byte detection. Progressive streaming decode fills `AudioDocument.pages` as codec chunks arrive, enabling editing before full decode completes.
|
||||
|
||||
---
|
||||
|
||||
## Current-state audit (genesis)
|
||||
|
||||
The package does not exist on genesis yet — this is a fresh build. No prior source, no test results, no coverage data.
|
||||
|
||||
---
|
||||
|
||||
## Available workspace deps on genesis
|
||||
|
||||
| Package | Role |
|
||||
|---|---|
|
||||
| `@suaveplan/audio-core` (`workspace:^`) | `AudioDocument`, `AudioPage`, `AudioStats` for decoded output |
|
||||
| `@suaveplan/audio-metadata` (`workspace:^`) | Magic-byte format detection and audio metadata extraction |
|
||||
| `@suaveplan/streams` (`workspace:^`) | WHATWG `TransformStream` / `ReadableStream` / `WritableStream` primitives |
|
||||
| `@suaveplan/error` (`workspace:^`) | Base class for `AudioCodecError`; `AUDIO_CODEC.*` codes |
|
||||
| `@suaveplan/types` (`workspace:^`) | `LoggerLike`, `Clock` |
|
||||
| `zod` (`catalog:forms`) | Zod schemas for all public option types |
|
||||
| `@opentelemetry/api` (`catalog:observability`) | Spans + counters on decode, encode, and detect ops |
|
||||
|
||||
Dev deps: `vitest`, `@vitest/coverage-istanbul`, `fast-check`, `mitata` (all `catalog:testing`).
|
||||
|
||||
---
|
||||
|
||||
## Public API
|
||||
|
||||
### Core types
|
||||
|
||||
```ts
|
||||
class AudioCodecError extends SuaveplanError
|
||||
|
||||
type Source =
|
||||
| string
|
||||
| URL
|
||||
| ArrayBuffer
|
||||
| Uint8Array
|
||||
| ReadableStream<Uint8Array>
|
||||
|
||||
type Destination =
|
||||
| string
|
||||
| WritableStream<Uint8Array>
|
||||
| ((chunk: Uint8Array) => void)
|
||||
```
|
||||
|
||||
### CodecRegistry
|
||||
|
||||
```ts
|
||||
class CodecRegistry {
|
||||
register(codec: AudioCodec): void
|
||||
unregister(name: string): void
|
||||
detect(bytes: Uint8Array): string | undefined
|
||||
|
||||
decode(source: Source, opts?: DecodeOpts): Promise<AudioDocument>
|
||||
encode(doc: AudioDocument, format: string, opts?: EncodeOpts): Promise<Uint8Array>
|
||||
|
||||
decodeStream(source: ReadableStream<Uint8Array>, format: string): ReadableStream<AudioPage>
|
||||
encodeStream(doc: AudioDocument, format: string): ReadableStream<Uint8Array>
|
||||
}
|
||||
```
|
||||
|
||||
### AudioCodec interface
|
||||
|
||||
```ts
|
||||
interface AudioCodec {
|
||||
readonly name: string
|
||||
readonly extensions: string[]
|
||||
readonly mimeTypes: string[]
|
||||
|
||||
canDecode(bytes: Uint8Array): boolean
|
||||
decode(bytes: Uint8Array | ArrayBuffer): Promise<DecodeResult>
|
||||
encode(pcm: Float32Array[], opts: EncodeOpts): Promise<Uint8Array>
|
||||
|
||||
createDecoder?(): StreamingDecoder
|
||||
createEncoder?(): StreamingEncoder
|
||||
}
|
||||
|
||||
interface DecodeResult {
|
||||
channelData: Float32Array[]
|
||||
sampleRate: number
|
||||
format: string
|
||||
}
|
||||
|
||||
interface StreamingDecoder {
|
||||
write(chunk: Uint8Array): Promise<DecodeResult | null>
|
||||
flush(): Promise<DecodeResult | null>
|
||||
}
|
||||
|
||||
interface StreamingEncoder {
|
||||
write(pcm: Float32Array[]): Promise<Uint8Array | null>
|
||||
flush(): Promise<Uint8Array | null>
|
||||
}
|
||||
```
|
||||
|
||||
### Universal entry points
|
||||
|
||||
```ts
|
||||
export async function decodeSource(
|
||||
source: Source,
|
||||
opts?: DecodeSourceOpts
|
||||
): Promise<AudioDocument>
|
||||
|
||||
interface DecodeSourceOpts {
|
||||
sampleRate?: number
|
||||
channels?: number
|
||||
format?: string
|
||||
pages?: AudioPage[]
|
||||
notify?(): void
|
||||
ondata?(delta: AudioStats): void
|
||||
storage?: 'memory' | 'persistent'
|
||||
cache?: PageCache
|
||||
budget?: number
|
||||
}
|
||||
|
||||
export async function encodeTo(
|
||||
doc: AudioDocument,
|
||||
destination: Destination,
|
||||
format: string,
|
||||
opts?: EncodeOpts
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
### Format detection
|
||||
|
||||
```ts
|
||||
const HEADER_SIZE = 256 * 1024
|
||||
|
||||
async function detectSource(source: Source): Promise<{
|
||||
format: string | undefined
|
||||
bytes?: Uint8Array
|
||||
path?: string
|
||||
reader?: ReadableStreamDefaultReader<Uint8Array>
|
||||
}>
|
||||
```
|
||||
|
||||
### Progressive streaming decode
|
||||
|
||||
```ts
|
||||
async function progressiveDecode(
|
||||
source: Source,
|
||||
opts: DecodeSourceOpts,
|
||||
registry: CodecRegistry
|
||||
): Promise<AudioDocument>
|
||||
```
|
||||
|
||||
Key feature: returned `AudioDocument` is usable immediately before decode completes; edits can be chained and extend automatically as more pages arrive.
|
||||
|
||||
### Built-in codecs
|
||||
|
||||
| Format | Decode | Encode | Streaming | Notes |
|
||||
|---|---|---|---|---|
|
||||
| WAV | ✅ | ✅ | ✅ | Native, no deps |
|
||||
| MP3 | ✅ | ✅ | ✅ | WASM (`@audio/decode-mp3`) |
|
||||
| FLAC | ✅ | ✅ | ✅ | WASM (`@audio/decode-flac`) |
|
||||
| Ogg Vorbis | ✅ | ❌ | ✅ | WASM |
|
||||
| Opus | ✅ | ✅ | ✅ | WASM |
|
||||
| AAC | ✅ | ❌ | ❌ | WASM |
|
||||
| AIFF | ✅ | ❌ | ❌ | Native |
|
||||
| CAF | ✅ | ❌ | ❌ | Native |
|
||||
| WebM | ✅ | ❌ | ❌ | WASM |
|
||||
| QOA | ✅ | ✅ | ✅ | WASM |
|
||||
|
||||
WASM modules are loaded on demand via dynamic `import()` and declared as `optionalDependencies`.
|
||||
|
||||
### Error codes
|
||||
|
||||
```ts
|
||||
type AudioCodecErrorCode =
|
||||
| 'AUDIO_CODECS.UNKNOWN_FORMAT'
|
||||
| 'AUDIO_CODECS.DECODE_FAILED'
|
||||
| 'AUDIO_CODECS.ENCODE_FAILED'
|
||||
| 'AUDIO_CODECS.STREAM_ERROR'
|
||||
| 'AUDIO_CODECS.NOT_SUPPORTED'
|
||||
| 'AUDIO_CODECS.INVALID_SOURCE'
|
||||
| 'AUDIO_CODECS.BUDGET_EXCEEDED'
|
||||
|
||||
const AUDIO_CODEC_ERROR_METADATA: Readonly<Record<AudioCodecErrorCode, CodeMetadata>>
|
||||
function registerAudioCodecErrorCodes(): void
|
||||
```
|
||||
|
||||
### Zod schemas
|
||||
|
||||
```ts
|
||||
DecodeOptsSchema
|
||||
EncodeOptsSchema
|
||||
DecodeSourceOptsSchema
|
||||
AudioCodecSchema
|
||||
StreamingDecoderSchema
|
||||
StreamingEncoderSchema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## New additions for genesis
|
||||
|
||||
1. **Zod boundary validation** — every public options object validated at entry.
|
||||
2. **OTel instrumentation** — spans on `codec.decode`, `codec.encode`, `codec.detect`; counters for `audio_codec_decode_total`, `audio_codec_encode_total`; histograms for `audio_codec_decode_duration_ms`, `audio_codec_encode_duration_ms`.
|
||||
3. **Memory budget enforcement** — `budget` option in `DecodeSourceOpts` aborts decode if projected memory exceeds limit.
|
||||
4. **Source resolution** — automatic `fetch()` vs `fs/promises.readFile()` resolution based on source type and runtime.
|
||||
5. **Page pre-allocation** — `pages` option allows callers to supply a pre-allocated page array for zero-copy progressive decode.
|
||||
6. **Property-based tests** — decode-then-encode round-trip for lossless formats (WAV, FLAC) yields identical PCM; format detection never returns undefined for known magic bytes; stream decoder yields same total samples as non-streaming decode.
|
||||
7. **Benchmark suite** — decode 5-minute WAV, encode 5-minute WAV, progressive stream decode 5-minute MP3.
|
||||
|
||||
---
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/media/audio/audio-codecs/
|
||||
package.json
|
||||
tsconfig.json
|
||||
vitest.config.ts
|
||||
src/
|
||||
index.ts
|
||||
error-codes.ts
|
||||
registry/
|
||||
registry.ts
|
||||
registry.md
|
||||
registry.types.ts
|
||||
registry.test.ts
|
||||
index.ts
|
||||
decode/
|
||||
decode-source.ts
|
||||
decode-source.md
|
||||
decode-source.types.ts
|
||||
decode-source.test.ts
|
||||
index.ts
|
||||
encode/
|
||||
encode-to.ts
|
||||
encode-to.md
|
||||
encode-to.types.ts
|
||||
encode-to.test.ts
|
||||
index.ts
|
||||
detect/
|
||||
detect-format.ts
|
||||
detect-format.md
|
||||
detect-format.test.ts
|
||||
index.ts
|
||||
stream/
|
||||
stream-decoder.ts
|
||||
stream-decoder.md
|
||||
stream-decoder.test.ts
|
||||
stream-encoder.ts
|
||||
stream-encoder.md
|
||||
stream-encoder.test.ts
|
||||
index.ts
|
||||
codecs/
|
||||
wav.ts
|
||||
wav.md
|
||||
wav.test.ts
|
||||
mp3.ts
|
||||
mp3.md
|
||||
mp3.test.ts
|
||||
flac.ts
|
||||
flac.md
|
||||
flac.test.ts
|
||||
index.ts
|
||||
__benchmarks__/
|
||||
decode.bench.ts
|
||||
encode.bench.ts
|
||||
stream-decode.bench.ts
|
||||
tests/
|
||||
integration.test.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Impact
|
||||
|
||||
**Breaking changes**: none — fresh build, package does not yet exist on genesis.
|
||||
|
||||
**Prerequisites**: `@suaveplan/audio-core`, `@suaveplan/audio-metadata`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types` published to Verdaccio. All five already exist on genesis.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
`@suaveplan/audio-codecs` sits directly above `@suaveplan/audio-core` and below every rendering
|
||||
engine and CLI in the audio family. At the time of this change none of the dependents exist on
|
||||
genesis yet — they land in later changes in this same batch — so there is no existing dependent
|
||||
surface to test or migrate. Each dependent is created against this package's spec delta, not
|
||||
retrofitted.
|
||||
|
||||
- `@suaveplan/audio-cli` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-cli`)
|
||||
- `@suaveplan/audio-engine-server` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-server`)
|
||||
- `@suaveplan/audio-engine-browser` — [ ] tests run / [ ] docs reviewed / [x] no action needed (reason: does not exist yet; created in `enterprise-audio-engine-browser`)
|
||||
|
||||
Once landed, any subsequent change to `@suaveplan/audio-codecs`' public surface MUST run
|
||||
`bunx turbo test --filter=...@suaveplan/audio-codecs` per `openspec/AGENTS.md` §13 step 10 to sweep
|
||||
these dependents.
|
||||
@@ -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 -->
|
||||
153
openspec/changes/enterprise-audio-codecs/tasks.md
Normal file
153
openspec/changes/enterprise-audio-codecs/tasks.md
Normal file
@@ -0,0 +1,153 @@
|
||||
# Tasks: enterprise-audio-codecs
|
||||
|
||||
**Change ID**: enterprise-audio-codecs
|
||||
**Package**: `@suaveplan/audio-codecs`
|
||||
**Target path**: `packages/media/audio/audio-codecs/`
|
||||
**Prerequisite packages on Verdaccio**: `@suaveplan/audio-core`, `@suaveplan/audio-metadata`, `@suaveplan/streams`, `@suaveplan/error`, `@suaveplan/types`
|
||||
|
||||
---
|
||||
|
||||
## 0. Pre-flight (non-negotiable)
|
||||
|
||||
- [ ] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
|
||||
- [ ] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
|
||||
- [ ] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [ ] Create directory `packages/media/audio/audio-codecs/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
|
||||
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/audio-core workspace:^`, `@suaveplan/audio-metadata workspace:^`, `@suaveplan/streams workspace:^`, `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`.
|
||||
- [ ] Declare optional peer deps / `optionalDependencies` for WASM codec packages (`@audio/decode-mp3`, `@audio/decode-flac`, etc.).
|
||||
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
|
||||
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
|
||||
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
|
||||
|
||||
---
|
||||
|
||||
## 2. Types + errors
|
||||
|
||||
- [ ] Write `src/registry/registry.types.ts` — `AudioCodec`, `DecodeResult`, `StreamingDecoder`, `StreamingEncoder`, `CodecRegistryOptions`.
|
||||
- [ ] Write `src/registry/registry.md`.
|
||||
- [ ] Write `src/decode/decode-source.types.ts` — `Source`, `DecodeSourceOpts`, `DecodeOpts`.
|
||||
- [ ] Write `src/decode/decode-source.md`.
|
||||
- [ ] Write `src/encode/encode-to.types.ts` — `Destination`, `EncodeOpts`.
|
||||
- [ ] Write `src/encode/encode-to.md`.
|
||||
- [ ] Write `src/stream/stream-decoder.types.ts` — streaming decoder config types.
|
||||
- [ ] Write `src/stream/stream-decoder.md`.
|
||||
- [ ] Write `src/stream/stream-encoder.types.ts` — streaming encoder config types.
|
||||
- [ ] Write `src/stream/stream-encoder.md`.
|
||||
- [ ] Write `src/error-codes.ts` — `AudioCodecErrorCode`, `AUDIO_CODEC_ERROR_METADATA`, `registerAudioCodecErrorCodes()`.
|
||||
- [ ] Write Zod schemas for every public options object (`DecodeOptsSchema`, `EncodeOptsSchema`, `DecodeSourceOptsSchema`, `AudioCodecSchema`, `StreamingDecoderSchema`, `StreamingEncoderSchema`).
|
||||
|
||||
---
|
||||
|
||||
## 3. Format detection
|
||||
|
||||
- [ ] Implement `detectSource(source)` — read first `HEADER_SIZE` (256KB) bytes, resolve string sources to `fetch()` or `fs/promises.readFile()` based on runtime, return format + bytes/reader.
|
||||
- [ ] Integrate `@suaveplan/audio-metadata` magic-byte detection.
|
||||
- [ ] Write `src/detect/detect-format.test.ts`.
|
||||
- [ ] Write `src/detect/detect-format.md`.
|
||||
|
||||
---
|
||||
|
||||
## 4. CodecRegistry
|
||||
|
||||
- [ ] Implement `CodecRegistry.register()` / `unregister()` / `detect()`.
|
||||
- [ ] Implement `CodecRegistry.decode(source, opts)` — dispatch to codec, return `AudioDocument`.
|
||||
- [ ] Implement `CodecRegistry.encode(doc, format, opts)` — dispatch to codec, return `Uint8Array`.
|
||||
- [ ] Implement `CodecRegistry.decodeStream()` — `ReadableStream<Uint8Array>` → `ReadableStream<AudioPage>` via `StreamingDecoder`.
|
||||
- [ ] Implement `CodecRegistry.encodeStream()` — `AudioDocument` → `ReadableStream<Uint8Array>` via `StreamingEncoder`.
|
||||
- [ ] Wire OTel span on `codec.decode` / `codec.encode` + counters + histograms.
|
||||
- [ ] Write `src/registry/registry.test.ts`.
|
||||
- [ ] Write `src/registry/registry.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Built-in codecs (native)
|
||||
|
||||
- [ ] Implement `WavCodec` — native WAV decode/encode with streaming support, no external deps.
|
||||
- [ ] Write `src/codecs/wav.test.ts` — round-trip, header validation, streaming chunks.
|
||||
- [ ] Write `src/codecs/wav.md`.
|
||||
- [ ] Implement `AiffCodec` — native AIFF decode (no encode), no external deps.
|
||||
- [ ] Implement `CafCodec` — native CAF decode (no encode), no external deps.
|
||||
|
||||
---
|
||||
|
||||
## 6. Built-in codecs (WASM wrappers)
|
||||
|
||||
- [ ] Implement `Mp3Codec` — dynamic `import('@audio/decode-mp3')` wrapper, decode + encode + streaming.
|
||||
- [ ] Write `src/codecs/mp3.test.ts` using deterministic fixtures (no network mocks).
|
||||
- [ ] Write `src/codecs/mp3.md`.
|
||||
- [ ] Implement `FlacCodec` — dynamic `import('@audio/decode-flac')` wrapper, decode + encode + streaming.
|
||||
- [ ] Write `src/codecs/flac.test.ts`.
|
||||
- [ ] Write `src/codecs/flac.md`.
|
||||
- [ ] Implement `OggVorbisCodec`, `OpusCodec`, `AacCodec`, `WebMCodec`, `QoaCodec` — decode wrappers with dynamic imports, optional streaming where supported.
|
||||
|
||||
---
|
||||
|
||||
## 7. Universal decode entry point
|
||||
|
||||
- [ ] Implement `decodeSource(source, opts)` — resolve source, detect format, dispatch to `CodecRegistry.decode`, support `notify` / `ondata` callbacks, page pre-allocation, memory budget.
|
||||
- [ ] Implement source resolution rules: `http/https/data/blob` → `fetch()`; `file:` → `fs/promises.readFile()`; other string → runtime-determined.
|
||||
- [ ] Write `src/decode/decode-source.test.ts`.
|
||||
- [ ] Write `src/decode/decode-source.md`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Universal encode entry point
|
||||
|
||||
- [ ] Implement `encodeTo(doc, destination, format, opts)` — encode then write to file path, `WritableStream`, or callback.
|
||||
- [ ] Write `src/encode/encode-to.test.ts`.
|
||||
- [ ] Write `src/encode/encode-to.md`.
|
||||
|
||||
---
|
||||
|
||||
## 9. Progressive streaming decode
|
||||
|
||||
- [ ] Implement `progressiveDecode()` — fill `AudioDocument.pages` as chunks arrive via `StreamingDecoder` or fallback full-decode.
|
||||
- [ ] Implement `pageAccumulator()` — push decoded PCM into pages, call `notify` on first page, call `ondata` per block.
|
||||
- [ ] Return usable `AudioDocument` immediately (before decode completes) with edits extending as pages arrive.
|
||||
- [ ] Write `src/stream/stream-decoder.test.ts`.
|
||||
- [ ] Write `src/stream/stream-decoder.md`.
|
||||
|
||||
---
|
||||
|
||||
## 10. Stream encoder
|
||||
|
||||
- [ ] Implement `createEncodeTransform()` — `TransformStream<Float32Array[], Uint8Array>` using `StreamingEncoder`.
|
||||
- [ ] Write `src/stream/stream-encoder.test.ts`.
|
||||
- [ ] Write `src/stream/stream-encoder.md`.
|
||||
|
||||
---
|
||||
|
||||
## 11. Memory budget
|
||||
|
||||
- [ ] Implement memory budget enforcement in `decodeSource` and `progressiveDecode` — project bytes from `sampleRate × channels × duration × 4` and throw `AudioCodecError(AUDIO_CODECS.BUDGET_EXCEEDED)` if over `budget`.
|
||||
- [ ] Write budget tests in `decode-source.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## 12. Benchmarks
|
||||
|
||||
- [ ] Write `src/__benchmarks__/decode.bench.ts` — decode 5-minute stereo WAV (native) and MP3 (WASM).
|
||||
- [ ] Write `src/__benchmarks__/encode.bench.ts` — encode 5-minute stereo WAV and FLAC.
|
||||
- [ ] Write `src/__benchmarks__/stream-decode.bench.ts` — progressive stream decode 5-minute MP3.
|
||||
|
||||
---
|
||||
|
||||
## 13. Integration + property tests
|
||||
|
||||
- [ ] Write `tests/integration.test.ts` — full round-trip: `decodeSource` → run edits → `encodeTo` → file written.
|
||||
- [ ] Write `tests/property.test.ts` (fast-check) — decode-then-encode round-trip for WAV/FLAC yields identical PCM; `detect` returns correct format for known magic bytes; streaming decode total samples equals non-streaming decode total samples.
|
||||
|
||||
---
|
||||
|
||||
## 14. Docs + verify + publish
|
||||
|
||||
- [ ] All co-located `.md` files (≥200 words each).
|
||||
- [ ] Write `README.md` at package root.
|
||||
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-codecs` — green.
|
||||
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-codecs` — 100%.
|
||||
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-codecs`.
|
||||
- [ ] Fold `specs/audio-codecs/spec.md` into `openspec/specs/audio-codecs/spec.md`.
|
||||
- [ ] Archive change folder to `openspec/archive/2026-05-14-enterprise-audio-codecs/`.
|
||||
Reference in New Issue
Block a user