Initial Commit
This commit is contained in:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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 -->
|
||||
@@ -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/`.
|
||||
Reference in New Issue
Block a user