149 lines
5.6 KiB
Markdown
149 lines
5.6 KiB
Markdown
# 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.
|