# 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: ```ts interface AudioAdapter { readonly supportedFormats: readonly AudioFormat[]; probe(data: Uint8Array): Promise; decode(data: Uint8Array): Promise; encode(samples: Float32Array, sampleRate: number, channels: number, format: AudioFormat): Promise; 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` 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` 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)