Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,148 @@
# Design: enterprise-audio-sprite-packer
**Change ID**: enterprise-audio-sprite-packer
**Package**: `@suaveplan/audio-sprite-packer`
`@suaveplan/audio-sprite-packer` packs multiple discrete audio clips into a single
concatenated binary with a compact JSON manifest. It is the universal-tier
counterpart to browser-specific sprite loaders: it performs the *packing* side of
the pipeline (server-side or build-time), producing a deterministic, hash-verified
sprite that browser or server consumers can later unpack by name.
The upstream package (`../suaveplan/packages/universal/audio-sprite-packer`)
compressed only metadata maps (`{name: [offset, duration]}`) using BitPacker. The
genesis migration retains that capability as the **manifest layer**, but adds the
**audio layer**: actual clip concatenation, format normalisation, silence stripping,
and gain normalisation via a pluggable `AudioAdapter`. The result is a
production-grade sprite builder rather than a map compressor.
---
## 1. Relationship to the ecosystem
`audio-sprite-packer` sits above `audio-core` (it produces `AudioDocument`-compatible
manifests) and below `audio-engine-browser` / `audio-engine-server` (which consume
sprites at runtime). It is a **leaf** in the dependency graph: no other genesis
package depends on it at build time; it is invoked at build time or on-demand.
`audio-sprite-packer` does NOT import `audio-core` directly — the manifest shape is
a stable JSON schema, not a runtime class dependency. This keeps the package
universal-tier with zero heavy audio dependencies.
---
## 2. Architecture
### 2.1. Two-layer output
Every `pack()` call produces:
1. **`data: Uint8Array`** — the concatenated, encoded audio bytes for all clips
(plus inter-clip silence padding when configured).
2. **`manifest: AudioSpriteManifest`** — JSON-serialisable metadata describing each
clip's byte offset, duration, sample rate, channel count, and format.
The separation lets consumers stream `data` from a CDN while downloading the small
manifest separately, or embed both in a single envelope.
### 2.2. AudioAdapter injection
All PCM operations are delegated to an injectable `AudioAdapter`:
- `probe` — extract duration, sample rate, channels, format from encoded bytes
- `decode` — convert encoded bytes to planar `Float32Array` PCM
- `encode` — convert planar PCM back to encoded bytes
- `stripSilence` — remove leading/trailing silence below a dB threshold
- `normalise` — peak-normalise to 0 dBFS
This makes `audio-sprite-packer` universal: a browser build passes a
`WebAudioAdapter` (wrapping `AudioContext.decodeAudioData`), a server build passes
an `FFmpegAdapter` (wrapping `fluent-ffmpeg` or `@ffmpeg/ffmpeg`), and unit tests
pass `NullAudioAdapter` which returns deterministic fixture data without any
external dependency.
### 2.3. Pack pipeline
```
clips[] → detect format → probe → decode → stripSilence? → normalise?
→ append silence padding → encode concatenated → build manifest
```
The pipeline is serial (not streaming) because the total output size is unknown
until all clips are processed. For very large clip sets, the caller can batch
externally.
### 2.4. Manifest integrity
The manifest carries a SHA-256 `contentHash` of the packed `data` buffer, computed
via `@suaveplan/crypto-utils`. `unpack(sprite, clipName)` validates the hash
lazily: it slices the requested clip by offset/byteLength and returns the bytes
without re-decoding. If the caller requests `verifyIntegrity: true`, `unpack`
re-computes the hash and throws `AUDIO_SPRITE_HASH_MISMATCH` on mismatch.
---
## 3. File layout
```text
packages/audio/audio-sprite-packer/
package.json
tsconfig.json
vitest.config.ts
README.md
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/
manifest.ts
manifest.md
manifest.test.ts
index.ts
__benchmarks__/
audio-sprite-packer.bench.ts
tests/
integration.test.ts
property.test.ts
```
---
## 4. Decisions and trade-offs
- **Serial pipeline, not streaming.** The output is a single concatenated buffer.
Streaming would require a two-pass design (write sizes, then rewrite headers)
which adds complexity for a build-time tool. Callers with >1000 clips should
batch externally.
- **NullAudioAdapter ships in `src/`, not `tests/`.** It is a legitimate testing
fixture that downstream packages can import via `@suaveplan/audio-sprite-packer/adapters/null-adapter`.
- **Manifest is JSON, not BitPacker.** The upstream used BitPacker for metadata
compression. The genesis manifest is plain JSON so it is human-readable,
diff-friendly, and trivially parsed in any runtime. The 80%+ compression claim
comes from the binary audio layer (concatenation + shared format), not from
metadata bit-packing.
- **Silence threshold is on the adapter.** `stripSilence` delegates to the adapter
so that different backends (Web Audio vs FFmpeg) can use their own optimised
algorithms. The packer only configures the threshold via `PackOptions`.
- **No `audio-core` runtime dependency.** The manifest references clips by name,
offset, and duration, not by `AudioPage` or `AudioDocument`. Consumers build
their own domain objects from the manifest. This prevents a heavy dependency
in a build-time tool.

View File

@@ -0,0 +1,197 @@
# 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
```ts
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
```ts
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
```ts
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
```text
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.

View File

@@ -0,0 +1,183 @@
<!-- markdownlint-disable MD024 -->
# 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"`
<!-- markdownlint-enable MD024 -->

View File

@@ -0,0 +1,83 @@
# Tasks: enterprise-audio-sprite-packer
**Change ID**: enterprise-audio-sprite-packer
**Package**: `@suaveplan/audio-sprite-packer`
**Target path**: `universal/compression/audio-sprite-packer`
**Prerequisite packages on Verdaccio**: `@suaveplan/error`, `@suaveplan/types`, `@suaveplan/mime`, `@suaveplan/crypto-utils`
---
## 1. Scaffold
- [ ] Create directory `universal/compression/audio-sprite-packer/` with `src/`, `tests/`, `src/__benchmarks__/`, and all feature subdirectories per the layout in `proposal.md`.
- [ ] Write `package.json` at version `0.1.0` with `"type": "module"`, `"sideEffects": false`, full `exports` map, deps: `@suaveplan/error workspace:^`, `@suaveplan/types workspace:^`, `@suaveplan/mime workspace:^`, `@suaveplan/crypto-utils workspace:^`, `zod catalog:forms`, `@opentelemetry/api catalog:observability`.
- [ ] Write `tsconfig.json` extending `@suaveplan/typescript-config/library.json`.
- [ ] Write `vitest.config.ts` using `createTestConfig()` from `@suaveplan/testing/config`.
- [ ] Add `lint`, `typecheck`, `test`, `test:coverage`, `build`, `docs:check` scripts.
---
## 2. Types + errors
- [ ] Write `src/types/common.ts` — `AudioFormat`, `AudioClipInput`, `AudioSpriteEntry`, `AudioSpriteManifest`, `AudioSprite`, `PackOptions`, `AudioAdapter`, `AudioSpritePackerOptions`.
- [ ] Write `src/types/common.md`.
- [ ] Write `src/error-codes.ts` — `AudioSpriteErrorCode`, `AUDIO_SPRITE_ERROR_METADATA`, `registerAudioSpriteErrorCodes()`.
- [ ] Write Zod schemas: `AudioSpriteEntrySchema`, `AudioSpriteManifestSchema`, `PackOptionsSchema`, `AudioSpritePackerOptionsSchema`.
---
## 3. NullAudioAdapter
- [ ] Implement `NullAudioAdapter` — `probe` returns configurable fixture metadata (WAV, 44100 Hz, 2ch, 1.0 s), `decode` returns 44100 × 2 float32 zeros, `encode` returns input bytes, `stripSilence` returns input unchanged, `normalise` returns input unchanged.
- [ ] Write `null-adapter.test.ts` and `null-adapter.md`.
---
## 4. Manifest utilities
- [ ] Implement `buildManifest(entries, packedBuffer): AudioSpriteManifest` — assembles manifest including SHA-256 content hash via `@suaveplan/crypto-utils`.
- [ ] Implement `validateManifest(manifest): void` — checks version, entries array non-empty, offsets non-overlapping.
- [ ] Write `manifest.test.ts` and `manifest.md`.
---
## 5. AudioSpritePacker
- [ ] Implement `AudioSpritePacker.pack(clips, options, signal)`:
1. Validate clips non-empty.
2. For each clip: detect format (`@suaveplan/mime`), probe, decode PCM.
3. If `stripSilence`, call `adapter.stripSilence`.
4. If `normalise`, call `adapter.normalise`.
5. Append silence padding (zero-fill float32 × sampleRate × silencePaddingMs / 1000).
6. Encode all clips concatenated as single output buffer.
7. Build manifest with byte offsets per clip.
8. Return `{ data, manifest }`.
- [ ] Implement `AudioSpritePacker.unpack(sprite, clipName)` — slice-and-return using manifest offsets.
- [ ] Wire `AbortSignal` propagation; throw on abort.
- [ ] Wire OTel: span `audio-sprite.pack`, counter, histogram, compression ratio.
- [ ] Write `audio-sprite-packer.test.ts` (≤500 lines): pack 3 clips, unpack each, abort, empty clips throws.
- [ ] Write `audio-sprite-packer.md`.
---
## 6. Benchmarks
- [ ] Write `src/__benchmarks__/audio-sprite-packer.bench.ts` — pack 10 clips, pack 50 clips (null adapter).
---
## 7. Tests
- [ ] Write `tests/integration.test.ts` — full pack/unpack round-trip.
- [ ] Write `tests/property.test.ts` (fast-check) — manifest entry count = clip count, unpack returns correct slice, silence padding duration.
---
## 8. Docs + verify + publish
- [ ] All co-located `.md` files (≥200 words each).
- [ ] Write `README.md` at package root.
- [ ] `bunx turbo lint typecheck test --filter=@suaveplan/audio-sprite-packer` — green.
- [ ] `bunx turbo test:coverage --filter=@suaveplan/audio-sprite-packer` — 100%.
- [ ] `bun scripts/publish.ts --package @suaveplan/audio-sprite-packer`.
- [ ] Archive to `openspec/archive/2026-04-24-enterprise-audio-sprite-packer/`.