423 lines
18 KiB
Markdown
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)
|