# Capability Spec Delta: audio-sprite-packer (audio-sprite-packer) **Package**: `@suaveplan/audio-sprite-packer` **Status**: PROPOSED > Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions. ## ADDED Requirements ### Requirement: Universal-tier purity The package SHALL import zero browser-only APIs (`window`, `document`, `navigator`, `AudioContext`) and zero statically-imported Node-only APIs (`node:fs`, `node:path`, `process`, `Buffer`). All audio encode/decode operations SHALL be delegated to the injectable `AudioAdapter`. The package's `dependencies` SHALL be limited to `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/mime`, `@suaveplan/crypto-utils`, `tslib`, and `zod`; `@opentelemetry/api` is the only permitted non-workspace runtime dependency. #### Scenario: Built dist references no platform globals at module scope - **WHEN** the built `dist/` is grep'd for top-level imports - **THEN** it MUST NOT statically import `window`, `document`, `navigator`, `AudioContext`, `node:fs`, `node:path`, `process`, or `Buffer` #### Scenario: Tests pass under both runtimes - **WHEN** the suite runs under `bun test` (server) and under vitest (browser shim) - **THEN** both runs pass with identical results using `NullAudioAdapter` **Verified by:** `tests/purity.test.ts::"dist statically imports no browser or Node globals"` AND `tests/purity.test.ts::"dependency set is limited to the declared workspace deps and zod"` --- ### Requirement: AudioAdapter plugin contract The package SHALL export an `AudioAdapter` interface carrying `supportedFormats`, `probe(data)`, `decode(data)`, `encode(samples, sampleRate, channels, format)`, `stripSilence(samples, sampleRate, thresholdDb?)`, and `normalise(samples)`. The package SHALL also export `NullAudioAdapter` implementing the interface: `probe` returns configurable fixture metadata, `decode` returns zeros, `encode` returns the input re-interpreted as bytes, `stripSilence` and `normalise` return the input unchanged. #### Scenario: NullAudioAdapter probes a fixture - **WHEN** `NullAudioAdapter.probe` is called with any Uint8Array - **THEN** it returns `{ format: "wav", durationSeconds: 1, sampleRate: 44100, channels: 2 }` #### Scenario: NullAudioAdapter round-trips decode/encode - **WHEN** `NullAudioAdapter.decode` is called and the result is passed to `encode` - **THEN** the returned bytes are byte-equal to the original input **Verified by:** `src/adapters/null-adapter/null-adapter.test.ts::"NullAudioAdapter.probe returns fixture metadata"` AND `src/adapters/null-adapter/null-adapter.test.ts::"decode/encode round-trips through NullAudioAdapter"` --- ### Requirement: AudioSpritePacker pack and unpack The package SHALL export `AudioSpritePacker` with constructor `options: { adapter: AudioAdapter; logger?: LoggerLike; clock?: Clock }` and methods `pack(clips, options?, signal?)` and `unpack(sprite, clipName)`. `pack` SHALL validate input is non-empty, probe and decode each clip via the adapter, optionally strip silence and normalise, append silence padding, encode the concatenated PCM, build a manifest with per-clip offsets, and return `{ data, manifest }`. `unpack` SHALL look up the clip name in the manifest and return a `Uint8Array` slice of the sprite data at the recorded offset and byteLength. #### Scenario: pack three clips and unpack each - **WHEN** `pack` is called with three `AudioClipInput` items using `NullAudioAdapter` - **THEN** the returned manifest has three entries with monotonically increasing offsets, and `unpack` for each name returns a Uint8Array whose byteLength matches the manifest entry #### Scenario: unpack for unknown clip name throws - **WHEN** `unpack` is called with a clip name not present in the manifest - **THEN** it throws `AudioSpriteError` whose `code === "AUDIO_SPRITE.CLIP_NOT_FOUND"` **Verified by:** `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack three clips and unpack each by name"` AND `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"unpack throws AUDIO_SPRITE_CLIP_NOT_FOUND for unknown name"` --- ### Requirement: Empty input rejection `pack` called with an empty clips array SHALL throw `AudioSpriteError` with code `AUDIO_SPRITE.EMPTY_INPUT` before invoking the adapter. #### Scenario: empty clips array throws EMPTY_INPUT - **WHEN** `pack` is called with `[]` - **THEN** it throws `AudioSpriteError` whose `code === "AUDIO_SPRITE.EMPTY_INPUT"` **Verified by:** `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack throws EMPTY_INPUT for empty clips array"` --- ### Requirement: Manifest build and validation The package SHALL export `buildManifest(entries, packedBuffer)` returning an `AudioSpriteManifest` with `version: 1`, `format`, `sampleRate`, `channels`, `totalDurationSeconds`, `entries`, and `contentHash` (SHA-256 of `packedBuffer`). It SHALL also export `validateManifest(manifest)` which checks `version === 1`, entries array non-empty, offsets non-overlapping, and throws `AudioSpriteError` with code `AUDIO_SPRITE.INVALID_MANIFEST` on any violation. #### Scenario: buildManifest computes SHA-256 content hash - **WHEN** `buildManifest` is called with entries and a packed buffer - **THEN** the manifest `contentHash` is a 64-character hex string matching the SHA-256 of the buffer #### Scenario: validateManifest rejects overlapping offsets - **WHEN** `validateManifest` is called with entries whose byte ranges overlap - **THEN** it throws `AudioSpriteError` whose `code === "AUDIO_SPRITE.INVALID_MANIFEST"` **Verified by:** `src/utils/manifest/manifest.test.ts::"buildManifest computes a SHA-256 content hash"` AND `src/utils/manifest/manifest.test.ts::"validateManifest rejects overlapping byte offsets"` --- ### Requirement: AbortSignal propagation `pack` SHALL accept an optional `AbortSignal`. An already-aborted signal SHALL cause the method to throw `AudioSpriteError` with code `AUDIO_SPRITE.ABORTED` before any adapter call. A signal aborted mid-pack SHALL terminate at the next clip boundary, release any partial buffers, and throw the same error. #### Scenario: pre-aborted signal short-circuits pack - **WHEN** `pack` is called with an already-aborted signal - **THEN** it throws `AudioSpriteError` whose `code === "AUDIO_SPRITE.ABORTED"` before the adapter is invoked **Verified by:** `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack throws ABORTED for a pre-aborted signal"` --- ### Requirement: Zod boundary validation Every public options object — `PackOptions`, `AudioSpritePackerOptions`, `AudioClipInput` — SHALL have a Zod schema, and every public entry point (`pack`, `createAudioSpritePacker`) SHALL validate its arguments against the schema before use. A validation failure SHALL throw `AudioSpriteError` with code `AUDIO_SPRITE.INVALID_OPTIONS` carrying the Zod issue list in `context`. #### Scenario: invalid options are rejected at the boundary - **WHEN** `pack` is called with `silencePaddingMs: -1` - **THEN** it throws `AudioSpriteError` whose `code === "AUDIO_SPRITE.INVALID_OPTIONS"` with the Zod issues in `context` **Verified by:** `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack rejects invalid options with AUDIO_SPRITE_INVALID_OPTIONS"` --- ### Requirement: Structured error codes The package SHALL register error codes via `@suaveplan/error`'s `defineCodes("AUDIO_SPRITE", [...])` yielding values `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`, `AUDIO_SPRITE.INVALID_MANIFEST`, and `AUDIO_SPRITE.ABORTED`. Every error thrown by the package SHALL be an `AudioSpriteError` extending `SuaveplanError`. Every registered code SHALL be reachable from a real call site exercised by tests. #### Scenario: every registered code is thrown by a call site - **WHEN** the test suite runs - **THEN** each code in the registry is asserted thrown by at least one test **Verified by:** `src/error-codes.test.ts::"every AUDIO_SPRITE code is reachable from a call site"` --- ### Requirement: OpenTelemetry instrumentation The package SHALL instrument `pack` via `@opentelemetry/api`: span `audio_sprite.pack` carrying `clipCount` and `outputFormat` attributes; counter `audio_sprite.pack_total`; histograms `audio_sprite.pack_duration_ms` and `audio_sprite.compression_ratio`. Instrumentation SHALL be a no-op when no exporter is registered and SHALL NOT run as an import-time side effect. #### Scenario: instrumentation is inert without an exporter - **WHEN** the package is imported and used with no OTel exporter registered - **THEN** no error is thrown and no global state is mutated at import time #### Scenario: a pack records span, counter, and histograms - **WHEN** `pack` runs against a real in-memory `MeterProvider` and `TracerProvider` - **THEN** one `audio_sprite.pack` span is recorded, `audio_sprite.pack_total` increments by 1, and both histograms record one observation each **Verified by:** `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"pack records OTel instruments when an exporter is registered"` AND `tests/purity.test.ts::"importing the package mutates no global state"` --- ### Requirement: Subpath exports per feature module `package.json` SHALL declare one `exports` entry per feature module (`./core/audio-sprite-packer/audio-sprite-packer`, `./adapters/null-adapter/null-adapter`, `./utils/manifest/manifest`) in addition to the root `.` barrel, each resolving to a typed `import` condition. `sideEffects` SHALL be `false`. #### Scenario: each feature module is independently importable - **WHEN** a consumer imports `@suaveplan/audio-sprite-packer/adapters/null-adapter` - **THEN** the import resolves to the NullAudioAdapter module without pulling the whole package barrel **Verified by:** `tests/exports.test.ts::"every feature module has a subpath export entry"` --- ### Requirement: Pack options silence padding and gain normalisation `PackOptions` SHALL support `silencePaddingMs` (default 100), `stripSilence` (default true), and `normalise` (default false). When `stripSilence` is true, the adapter's `stripSilence` SHALL be called per clip before concatenation. When `normalise` is true, the adapter's `normalise` SHALL be called per clip. Silence padding SHALL append `sampleRate × channels × silencePaddingMs / 1000` zero-valued float samples between clips. #### Scenario: silence padding increases total duration - **WHEN** two clips are packed with `silencePaddingMs: 100` - **THEN** the manifest `totalDurationSeconds` is greater than the sum of the raw clip durations by approximately 0.1 seconds #### Scenario: normalised clips have consistent peak amplitude - **WHEN** `pack` is called with `normalise: true` and clips of varying amplitude - **THEN** each clip is passed to `adapter.normalise` before encoding **Verified by:** `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"silence padding increases total duration"` AND `src/core/audio-sprite-packer/audio-sprite-packer.test.ts::"normalise invokes adapter.normalise per clip"`