Initial Commit
This commit is contained in:
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.
|
||||
Reference in New Issue
Block a user