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