18 KiB
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 inpackages/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 forwindow,document,node:fs,AudioContext,Buffer - THEN the count is zero
Scenario: dependency surface is fixed
- WHEN
package.jsondependenciesis 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:
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
packandunpack
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
AudioSpriteManifestSchemaparses a manifest withentries: [] - THEN Zod throws
Scenario: contentHash length pinned to 64 hex chars
- WHEN
AudioSpriteManifestSchemaparses a manifest withcontentHashof 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:
- Validate
options(Zod) → throwAudioSpriteError("INVALID_OPTIONS")on failure. - Throw
AudioSpriteError("EMPTY_INPUT")ifclips.length === 0. - Check
signal?.abortedbefore processing — throwAudioSpriteError("ABORTED")if true. - Validate each clip (Zod) → throw
AudioSpriteError("INVALID_OPTIONS")on failure. - For each clip:
adapter.probe→adapter.decode→ optionaladapter.stripSilence(whenstripSilence: true) → optionaladapter.normalise(whennormalise: true) → append zero-filled silence padding (length =Math.floor(sampleRate * channels * silencePaddingMs / 1000)) →adapter.encode→ record entry withoffset: 0placeholder. - Check
signal?.abortedbetween clips — throwAudioSpriteError("ABORTED")mid-flight. - Concatenate every encoded chunk into a single
Uint8Array. - Patch entries'
offsetfields in place. - Build manifest with SHA-256
contentHashover the packed buffer. - Emit OTel span + counter + histograms.
- Return
{ data, manifest }.
Scenario: three clips pack and unpack by name
- WHEN caller packs three named clips through
packand then callsunpack(sprite, name)on each - THEN every returned
Uint8Arraymatches the original encoded bytes (after pipeline)
Scenario: silence padding increases total duration
- WHEN caller packs two clips with
silencePaddingMs: 200 - THEN
manifest.totalDurationSecondsequals 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
buildManifestruns twice over the same packed bytes - THEN both runs produce identical
contentHashstrings
Scenario: hash length pinned to 64
- WHEN any successful
buildManifestcall returns - THEN
manifest.contentHash.length === 64
Scenario: version literal is 1
- WHEN
buildManifestreturns - 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 rejectversion !== 1`, empty entries, and sparse / undefined entry slots.
Scenario: overlap is rejected
- WHEN
validateManifestruns 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
compressionRatiois0and 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
decodeis called with default fixture - THEN the returned
Float32Arrayhas length44100 * 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.bufferbytes
Scenario: stripSilence and normalise pass through
- WHEN caller invokes either method
- THEN the returned
Float32Arrayis===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.tsruns - THEN at least one property is checked and passes
Scenario: integration test exists and runs
- WHEN
vitest run tests/integration.test.tsruns - 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-packerruns - THEN statements / branches / functions / lines are each
100
Verified by: bunx turbo test:coverage --filter=@suaveplan/audio-sprite-packer (CI gate)