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

18 KiB

Capability Spec: audio-sprite-packer

Package: @suaveplan/audio-sprite-packer Status: STABLE Tier: universal

Every requirement below carries a **Verified by:** line anchored to a real test in packages/media/audio/audio-sprite-packer/. No exceptions.

@suaveplan/audio-sprite-packer packs many short audio clips into a single contiguous byte buffer with a content-addressable manifest that records each clip's byte offset, length, sample rate, channel count, and duration. The packer is codec-agnostic: all encode/decode/DSP work is delegated to an injected AudioAdapter. A NullAudioAdapter is shipped for tests and fixtures.

Requirements

Requirement: Universal-tier purity with codec abstraction

The package SHALL import zero browser-only APIs (window, document, navigator, AudioContext, AudioBuffer) and zero Node-only APIs (node:fs, node:path, process, Buffer). All codec work — probe, decode, encode, stripSilence, normalise — SHALL be reached only through the injected AudioAdapter. The package's runtime dependencies SHALL be limited to @suaveplan/crypto-hash, @suaveplan/crypto-utils, @suaveplan/error, @suaveplan/mime, @suaveplan/types, @opentelemetry/api, tslib, and zod.

Scenario: built dist references no platform globals

  • WHEN dist/ is grep'd for window, document, node:fs, AudioContext, Buffer
  • THEN the count is zero

Scenario: dependency surface is fixed

  • WHEN package.json dependencies is inspected
  • THEN the set is exactly the allow-list above

Verified by: tests/purity.test.ts, tests/exports.test.ts


Requirement: AudioAdapter contract injected at construction

The package SHALL export an AudioAdapter interface:

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

The AudioSpritePacker constructor SHALL accept exactly { adapter: AudioAdapter }, validated via AudioSpritePackerOptionsSchema (Zod z.custom checking presence of a probe function). Invalid options SHALL throw AudioSpriteError("INVALID_OPTIONS"). The adapter SHALL be the only injection point — the packer SHALL NOT import any codec library or platform API directly.

Scenario: construction with non-object rejects

  • WHEN new AudioSpritePacker(null as any) is called
  • THEN the constructor throws AudioSpriteError("INVALID_OPTIONS")

Scenario: construction with valid options succeeds

  • WHEN new AudioSpritePacker({ adapter: nullAdapter }) is called
  • THEN the instance is created and exposes pack and unpack

Verified by: src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"rejects invalid options at construction", src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"constructs with valid options"


Requirement: AudioFormat enum and supporting types

The package SHALL export AudioFormat as a Zod enum containing exactly: "wav" | "mp3" | "ogg" | "flac" | "unknown". The package SHALL export AudioClipInput ({ name: string (min 1), data: Uint8Array, format?: AudioFormat }), AudioProbeResult ({ durationSeconds, sampleRate, channels, format }), AudioSpriteEntry ({ name, offset, byteLength, durationSeconds, sampleRate, channels, format }), AudioSpriteManifest ({ version: 1, format, sampleRate, channels, totalDurationSeconds, entries: [≥ 1], contentHash: string (length 64) }), and AudioSprite ({ data: Uint8Array, manifest: AudioSpriteManifest }). Every type SHALL have a corresponding Zod schema with a *Schema suffix.

Scenario: manifest requires non-empty entries

  • WHEN AudioSpriteManifestSchema parses a manifest with entries: []
  • THEN Zod throws

Scenario: contentHash length pinned to 64 hex chars

  • WHEN AudioSpriteManifestSchema parses a manifest with contentHash of any length other than 64
  • THEN Zod throws

Verified by: src/utils/manifest/manifest.test.ts::"rejects empty entries", src/utils/manifest/manifest.test.ts::"computes a SHA-256 content hash" (verifies 64-char output)


Requirement: PackOptions with documented defaults

The package SHALL export PackOptions accepting outputFormat: AudioFormat (default "ogg"), silencePaddingMs: number (non-negative integer, default 100), stripSilence: boolean (default true), normalise: boolean (default false), sampleRate?: number (positive integer when present). PackOptionsSchema SHALL apply these defaults via Zod's .default(...) chain so callers MAY pass undefined or any subset.

Scenario: defaults applied for missing fields

  • WHEN PackOptionsSchema.parse({}) runs
  • THEN result is { outputFormat: "ogg", silencePaddingMs: 100, stripSilence: true, normalise: false }

Verified by: src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"silence padding increases total duration" (implicitly exercises defaults via pack flow)


Requirement: pack pipeline for n clips

AudioSpritePacker.pack(clips, options?, signal?): Promise<AudioSprite> SHALL execute this pipeline:

  1. Validate options (Zod) → throw AudioSpriteError("INVALID_OPTIONS") on failure.
  2. Throw AudioSpriteError("EMPTY_INPUT") if clips.length === 0.
  3. Check signal?.aborted before processing — throw AudioSpriteError("ABORTED") if true.
  4. Validate each clip (Zod) → throw AudioSpriteError("INVALID_OPTIONS") on failure.
  5. For each clip: adapter.probe → adapter.decode → optional adapter.stripSilence (when stripSilence: true) → optional adapter.normalise (when normalise: true) → append zero-filled silence padding (length = Math.floor(sampleRate * channels * silencePaddingMs / 1000)) → adapter.encode → record entry with offset: 0 placeholder.
  6. Check signal?.aborted between clips — throw AudioSpriteError("ABORTED") mid-flight.
  7. Concatenate every encoded chunk into a single Uint8Array.
  8. Patch entries' offset fields in place.
  9. Build manifest with SHA-256 contentHash over the packed buffer.
  10. Emit OTel span + counter + histograms.
  11. Return { data, manifest }.

Scenario: three clips pack and unpack by name

  • WHEN caller packs three named clips through pack and then calls unpack(sprite, name) on each
  • THEN every returned Uint8Array matches the original encoded bytes (after pipeline)

Scenario: silence padding increases total duration

  • WHEN caller packs two clips with silencePaddingMs: 200
  • THEN manifest.totalDurationSeconds equals the sum of per-clip durations plus the padding contribution

Scenario: empty clips array throws EMPTY_INPUT

  • WHEN pack([]) is called
  • THEN the promise rejects with AudioSpriteError("AUDIO_SPRITE.EMPTY_INPUT")

Scenario: invalid clip input throws INVALID_OPTIONS

  • WHEN pack([{ name: "", data: new Uint8Array() }]) is called
  • THEN the promise rejects with AudioSpriteError("AUDIO_SPRITE.INVALID_OPTIONS")

Verified by: src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack three clips and unpack each by name", src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"silence padding increases total duration", src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"throws EMPTY_INPUT for empty clips array", src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"rejects invalid clip input with AUDIO_SPRITE_INVALID_OPTIONS"


Requirement: AbortSignal cooperative cancellation

pack SHALL accept an optional AbortSignal and check signal.aborted both before processing and between each clip. When aborted, the pipeline SHALL throw AudioSpriteError("ABORTED") (severity warning per metadata). Mid-flight abort SHALL leave no partial sprite — the rejection propagates the abort without returning a manifest.

Scenario: pre-aborted signal throws immediately

  • WHEN pack(clips, options, AbortSignal.abort()) is called
  • THEN the promise rejects with AudioSpriteError("AUDIO_SPRITE.ABORTED") before any adapter call

Scenario: mid-flight abort throws

  • WHEN a signal is aborted after the first clip has been processed but before the second
  • THEN the promise rejects with AudioSpriteError("AUDIO_SPRITE.ABORTED") and no sprite is returned

Verified by: src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"throws ABORTED for pre-aborted signal", src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"throws ABORTED for mid-flight abort"


Requirement: unpack returns zero-copy view via manifest offsets

AudioSpritePacker.unpack(sprite, clipName): Uint8Array SHALL find the matching entry by name in sprite.manifest.entries and return a view of sprite.data via Uint8Array.slice(entry.offset, entry.offset + entry.byteLength). Missing names SHALL throw AudioSpriteError("CLIP_NOT_FOUND"). The returned slice MUST agree with the bytes the adapter produced for that clip.

Scenario: unknown name throws CLIP_NOT_FOUND

  • WHEN unpack(sprite, "no-such-clip") is called
  • THEN the function throws AudioSpriteError("AUDIO_SPRITE.CLIP_NOT_FOUND")

Verified by: src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"throws AUDIO_SPRITE_CLIP_NOT_FOUND for unknown name", src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack three clips and unpack each by name"


Requirement: SHA-256 content hash over packed buffer

buildManifest(entries, format, sampleRate, channels, packedBuffer) SHALL compute a content hash via @suaveplan/crypto-hash SHA-256 over the packed buffer bytes only (not the manifest JSON, not the entries metadata). The hash SHALL be encoded to hex via @suaveplan/crypto-utils's toHex, producing exactly 64 hex characters. The manifest version field SHALL be pinned to the literal 1 for forward compatibility.

Scenario: deterministic hash for identical buffer

  • WHEN buildManifest runs twice over the same packed bytes
  • THEN both runs produce identical contentHash strings

Scenario: hash length pinned to 64

  • WHEN any successful buildManifest call returns
  • THEN manifest.contentHash.length === 64

Scenario: version literal is 1

  • WHEN buildManifest returns
  • THEN manifest.version === 1

Verified by: src/utils/manifest/manifest.test.ts::"computes a SHA-256 content hash", src/utils/manifest/manifest.test.ts::"sets version to 1"


Requirement: validateManifest detects overlapping byte ranges

validateManifest(manifest): manifest is AudioSpriteManifest SHALL perform pairwise overlap detection over every entry: an overlap exists when `entry_i.offset < entry_j.offset + entry_j.byteLength && entry_j.offset < entry_i.offset

  • entry_i.byteLength. Adjacent non-overlapping entries (offset_b = offset_a + byteLength_a) SHALL pass. The validator SHALL also reject version !== 1`, empty entries, and sparse / undefined entry slots.

Scenario: overlap is rejected

  • WHEN validateManifest runs against entries with overlapping offsets
  • THEN the validator throws AudioSpriteError("AUDIO_SPRITE.INVALID_MANIFEST")

Scenario: adjacent ranges accepted

  • WHEN two entries are { offset: 0, byteLength: 100 } and { offset: 100, byteLength: 50 }
  • THEN the validator passes

Scenario: sparse entries handled defensively

  • WHEN the manifest entries array contains undefined slots
  • THEN the validator throws rather than crashing on undefined access

Verified by: src/utils/manifest/manifest.test.ts::"rejects overlapping byte offsets", src/utils/manifest/manifest.test.ts::"allows adjacent non-overlapping offsets", src/utils/manifest/manifest.test.ts::"handles sparse entry arrays defensively", src/utils/manifest/manifest.test.ts::"rejects version !== 1"


Requirement: Eight-code error registry under AUDIO_SPRITE.* namespace

The package SHALL declare AUDIO_SPRITE_CODES = defineCodes("AUDIO_SPRITE", [...]) containing exactly these eight codes: UNSUPPORTED_FORMAT, DECODE_FAILED, ENCODE_FAILED, CLIP_NOT_FOUND, INVALID_OPTIONS, EMPTY_INPUT, INVALID_MANIFEST, ABORTED. The package SHALL expose AUDIO_SPRITE_ERROR_METADATA mapping each code to a frozen { description, severity } object. Severity SHALL be "error" for all codes except ABORTED which is "warning". The package SHALL export AudioSpriteError extends SuaveplanError<AudioSpriteErrorCode> with a widened-public constructor and an idempotent registerAudioSpriteErrorCodes() hook called automatically at module load.

Scenario: every code has metadata

  • WHEN caller iterates AUDIO_SPRITE_CODES
  • THEN every value has a matching key in AUDIO_SPRITE_ERROR_METADATA

Scenario: registration is idempotent

  • WHEN registerAudioSpriteErrorCodes() is invoked multiple times
  • THEN repeated calls succeed without mutating metadata

Scenario: every code is reachable from a call site

  • WHEN static analysis follows throw sites in src/
  • THEN every code value is thrown from at least one location

Verified by: src/error-codes.test.ts::"metadata covers every code", src/error-codes.test.ts::"registerAudioSpriteErrorCodes is idempotent", src/error-codes.test.ts::"every AUDIO_SPRITE code is reachable from a call site"


Requirement: OpenTelemetry instrumentation

The packer SHALL acquire a tracer named "audio-sprite-packer" and a meter named "audio-sprite-packer". It SHALL emit one span per pack call (audio_sprite.pack) with attributes clipCount, outputFormat, and compressionRatio (computed as 1 - packedBuffer.length / originalSize, or 0 when originalSize === 0). On success the span status code SHALL be 1 (OK); on failure 2 (ERROR). It SHALL also emit one counter increment (audio_sprite.pack_total), one duration histogram sample (audio_sprite.pack_duration_ms), and one compression-ratio histogram sample (audio_sprite.compression_ratio) per successful pack. When no exporter is registered, all calls SHALL be no-ops with zero overhead.

Scenario: instruments recorded when exporter present

  • WHEN an in-memory OTel exporter is registered and a 3-clip pack succeeds
  • THEN the exporter observes one span with the documented attributes and the three metric instruments

Scenario: compression ratio of 0 when original size is 0

  • WHEN all clip payloads are zero-byte
  • THEN the span attribute compressionRatio is 0 and no division-by-zero error occurs

Verified by: src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack records OTel instruments when an exporter is registered", src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"computes compression ratio of 0 when original size is 0"


Requirement: NullAudioAdapter for tests and fixtures

The package SHALL export NullAudioAdapter (and createNullAudioAdapter(opts?) factory) implementing AudioAdapter with deterministic fixtures: supportedFormats: ["wav", "mp3", "ogg", "flac"], probe returning a configured { durationSeconds, sampleRate, channels, format } envelope (defaults 1, 44100, 2, "wav"), decode returning a zero-filled Float32Array of length floor(sampleRate * channels * durationSeconds), encode returning a self-describing buffer containing a JSON header { sampleRate, channels, format, length } followed by raw sample bytes, and stripSilence/normalise returning their input unchanged. NullAudioAdapter is for tests only; production must use a codec-backed adapter (e.g. ffmpeg or Web Audio).

Scenario: decode returns zero-filled buffer of expected length

  • WHEN decode is called with default fixture
  • THEN the returned Float32Array has length 44100 * 2 * 1

Scenario: encode round-trip preserves sample bytes

  • WHEN caller round-trips through decode → encode
  • THEN the encoded buffer contains the JSON header followed by samples.buffer bytes

Scenario: stripSilence and normalise pass through

  • WHEN caller invokes either method
  • THEN the returned Float32Array is === the input

Verified by: src/adapters/null-adapter/null-adapter.test.ts (every test)


Requirement: Subpath exports

The package SHALL expose, in addition to the root barrel, these subpaths: ./core/audio-sprite-packer/audio-sprite-packer, ./adapters/null-adapter/null-adapter, ./utils/manifest/manifest, ./error-codes. This enables consumers to import only the surfaces they need.

Scenario: each declared subpath resolves

  • WHEN a consumer imports each subpath
  • THEN all four resolutions succeed

Verified by: tests/exports.test.ts, tests/tree-shake.test.ts (implicit)


Requirement: Property-based round-trip and integration coverage

The package SHALL include at minimum: a property test verifying that any sequence of valid clips can be packed and every clip unpacked to its encoded bytes (tests/property.test.ts); an integration test exercising the full pack-then-unpack flow with the NullAudioAdapter (tests/integration.test.ts); and a purity test enforcing the universal-tier dependency rules (tests/purity.test.ts).

Scenario: property test exists and runs

  • WHEN vitest run tests/property.test.ts runs
  • THEN at least one property is checked and passes

Scenario: integration test exists and runs

  • WHEN vitest run tests/integration.test.ts runs
  • THEN the full pipeline is exercised against NullAudioAdapter

Verified by: tests/property.test.ts, tests/integration.test.ts


Requirement: 100% statement, branch, function, and line coverage

The package SHALL achieve 100% Istanbul coverage on every .ts under src/ excluding .test.ts, .spec.ts, .types.ts, and barrels. Coverage gate failure SHALL block publish.

Scenario: coverage gate

  • WHEN bunx turbo test:coverage --filter=@suaveplan/audio-sprite-packer runs
  • THEN statements / branches / functions / lines are each 100

Verified by: bunx turbo test:coverage --filter=@suaveplan/audio-sprite-packer (CI gate)