Files
suaveplan/openspec/changes/enterprise-audio-codecs/proposal.md
2026-05-27 23:29:53 +01:00

9.1 KiB

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

class AudioCodecError extends SuaveplanError

type Source =
  | string
  | URL
  | ArrayBuffer
  | Uint8Array
  | ReadableStream<Uint8Array>

type Destination =
  | string
  | WritableStream<Uint8Array>
  | ((chunk: Uint8Array) => void)

CodecRegistry

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

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

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

const HEADER_SIZE = 256 * 1024

async function detectSource(source: Source): Promise<{
  format: string | undefined
  bytes?: Uint8Array
  path?: string
  reader?: ReadableStreamDefaultReader<Uint8Array>
}>

Progressive streaming decode

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

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

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

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.