Files
suaveplan/openspec/specs/audio-sprite-packer/spec.md
2026-05-27 23:29:53 +01:00

423 lines
18 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# 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<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)