305 lines
9.1 KiB
Markdown
305 lines
9.1 KiB
Markdown
# 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.
|