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

6.5 KiB
Raw Permalink Blame History

OpenSpec Change: enterprise-audio-sprite-packer

  • Change ID: enterprise-audio-sprite-packer
  • Package: @suaveplan/audio-sprite-packer
  • Category: universal
  • Target path: universal/compression/audio-sprite-packer
  • Status: PROPOSED
  • Date: 2026-04-24
  • Reference spec: /mnt/s/web/suaveplan/enterprise/audio-sprite-packer.md

Summary

Deliver @suaveplan/audio-sprite-packer on the genesis branch from scratch at enterprise quality. The package builds binary audio sprite maps with 80%+ compression versus individual files: pack multiple audio clips (WAV/MP3/OGG) into a single concatenated binary with a compact JSON manifest (offset + duration per clip), strip inter-clip silence, normalise gain, and produce a ready-to-use sprite descriptor compatible with Web Audio API AudioBufferSourceNode. Pure TypeScript; audio encode/decode delegated to injectable AudioAdapter.


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/error (workspace:^) Base class for AudioSpriteError; AUDIO_SPRITE.* codes
@suaveplan/types (workspace:^) LoggerLike, Clock
@suaveplan/mime (workspace:^) Audio MIME-type detection
@suaveplan/crypto-utils (workspace:^) Content hash for deterministic manifest IDs
zod (catalog:forms) Zod schemas for all public option types
@opentelemetry/api (catalog:observability) Spans + counters

Dev deps: vitest, @vitest/coverage-istanbul, fast-check, mitata (all catalog:testing).


Public API

Core types

class AudioSpriteError extends SuaveplanError

type AudioFormat = 'wav' | 'mp3' | 'ogg' | 'flac' | 'unknown'

interface AudioClipInput {
  name: string
  data: Uint8Array
  format?: AudioFormat       // detected from @suaveplan/mime if absent
}

interface AudioSpriteEntry {
  name: string
  offset: number             // byte offset in the packed buffer
  byteLength: number
  durationSeconds: number
  sampleRate: number
  channels: number
  format: AudioFormat
}

interface AudioSpriteManifest {
  version: 1
  format: AudioFormat
  sampleRate: number
  channels: number
  totalDurationSeconds: number
  entries: AudioSpriteEntry[]
  contentHash: string         // SHA-256 of packed buffer
}

interface AudioSprite {
  data: Uint8Array            // concatenated packed audio bytes
  manifest: AudioSpriteManifest
}

interface PackOptions {
  outputFormat?: AudioFormat   // default: 'ogg'
  silencePaddingMs?: number    // between clips; default: 100
  stripSilence?: boolean       // default: true; remove leading/trailing silence per clip
  normalise?: boolean          // default: false; gain normalisation
  sampleRate?: number          // default: from first clip
}

interface AudioAdapter {
  readonly supportedFormats: readonly AudioFormat[]
  probe(data: Uint8Array): Promise<{ durationSeconds: number; sampleRate: number; channels: number; format: AudioFormat }>
  decode(data: Uint8Array): Promise<Float32Array>                       // PCM samples
  encode(samples: Float32Array, sampleRate: number, channels: number, format: AudioFormat): Promise<Uint8Array>
  stripSilence(samples: Float32Array, sampleRate: number, thresholdDb?: number): Float32Array
  normalise(samples: Float32Array): Float32Array
}

interface AudioSpritePackerOptions {
  adapter: AudioAdapter
  logger?: LoggerLike
  clock?: Clock
}

class AudioSpritePacker {
  constructor(options: AudioSpritePackerOptions)
  pack(clips: AudioClipInput[], options?: PackOptions, signal?: AbortSignal): Promise<AudioSprite>
  unpack(sprite: AudioSprite, clipName: string): Uint8Array
}

function createAudioSpritePacker(options: AudioSpritePackerOptions): AudioSpritePacker

Error codes

type AudioSpriteErrorCode =
  | 'AUDIO_SPRITE_UNSUPPORTED_FORMAT'
  | 'AUDIO_SPRITE_DECODE_FAILED'
  | 'AUDIO_SPRITE_ENCODE_FAILED'
  | 'AUDIO_SPRITE_CLIP_NOT_FOUND'
  | 'AUDIO_SPRITE_INVALID_OPTIONS'
  | 'AUDIO_SPRITE_EMPTY_INPUT'

const AUDIO_SPRITE_ERROR_METADATA: Readonly<Record<AudioSpriteErrorCode, CodeMetadata>>
function registerAudioSpriteErrorCodes(): void

Zod schemas

AudioSpriteEntrySchema
AudioSpriteManifestSchema
PackOptionsSchema
AudioSpritePackerOptionsSchema

New additions for genesis

  1. AudioAdapter interface — all PCM operations delegated; NullAudioAdapter ships for tests.
  2. Silence stripping — optional per-clip silence removal using a configurable dB threshold (default: −60 dBFS).
  3. Gain normalisation — peak normalise each clip to 0 dBFS before packing (optional).
  4. Content hashing — SHA-256 of packed buffer via @suaveplan/crypto-utils stored in manifest for integrity verification on unpack.
  5. unpack — slice-and-return: uses manifest.entries to extract a named clip from the sprite buffer without any re-decode.
  6. AbortSignal propagation — passed through pack; throws AudioSpriteError(AUDIO_SPRITE_ENCODE_FAILED) on abort.
  7. OTel instrumentation — span audio-sprite.pack, counter audio_sprite_pack_total, histogram audio_sprite_pack_duration_ms + audio_sprite_compression_ratio.
  8. Property-based tests — unpack after pack returns original bytes (null adapter), manifest entry count matches input clip count, silence padding produces correct total duration.
  9. Benchmark suite — pack 10 short clips (null adapter), pack 50 clips.

File layout

universal/compression/audio-sprite-packer/
  package.json
  tsconfig.json
  vitest.config.ts
  src/
    index.ts
    error-codes.ts
    constants/
      index.ts
    types/
      common.ts
      common.md
    core/
      audio-sprite-packer/
        audio-sprite-packer.ts
        audio-sprite-packer.md
        audio-sprite-packer.types.ts
        audio-sprite-packer.test.ts
        index.ts
    adapters/
      null-adapter/
        null-adapter.ts
        null-adapter.md
        null-adapter.test.ts
        index.ts
    utils/
      manifest.ts
      manifest.md
      manifest.test.ts
    __benchmarks__/
      audio-sprite-packer.bench.ts
  tests/
    integration.test.ts
    property.test.ts

Impact

Downstream: game audio engines, @suaveplan/audio-engine, any Web Audio sprite loaders. Breaking changes: none — fresh build. Prerequisites: @suaveplan/error, @suaveplan/types, @suaveplan/mime, @suaveplan/crypto-utils published to Verdaccio.