Files
2026-05-27 23:29:53 +01:00

184 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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 -->